From f5c09a93c018a8554ebcf3451c2ef76a8f054efd Mon Sep 17 00:00:00 2001 From: kendallbaertlein Date: Mon, 9 Dec 2024 15:00:15 -0700 Subject: [PATCH 01/14] added detailed performance data and some calculations (not fully functional) --- ochre/utils/hpxml.py | 185 +++++++++++++++++++++++++++++++++++-------- 1 file changed, 154 insertions(+), 31 deletions(-) diff --git a/ochre/utils/hpxml.py b/ochre/utils/hpxml.py index 8cf946d3..2960a614 100644 --- a/ochre/utils/hpxml.py +++ b/ochre/utils/hpxml.py @@ -9,7 +9,7 @@ ZONE_NAME_OPTIONS = { - 'Indoor': ['conditioned space', 'living space'], + 'Indoor': ['conditioned space'], 'Foundation': ['crawlspace', 'basement', 'finishedbasement', 'basement - conditioned', 'basement - unconditioned', 'crawlspace - vented', 'crawlspace - unvented'], 'Garage': ['garage'], @@ -281,7 +281,6 @@ def parse_hpxml_boundaries(hpxml, return_boundary_dicts=False, **kwargs): adj_walls = all_walls.pop(('Indoor', 'Indoor'), {}) adj_attic_walls = all_walls.pop(('Attic', 'Attic'), {}) adj_gar_walls = all_walls.pop(('Garage', 'Garage'), {}) - attic_gar_walls = all_walls.pop(('Garage', 'Attic'), {}) #FIXME: What do we do with these walls? assert not all_walls # verifies that all boundaries are accounted for # Get foundation walls @@ -350,7 +349,6 @@ def parse_hpxml_boundaries(hpxml, return_boundary_dicts=False, **kwargs): 'Foundation Wall': fnd_walls, # 'Foundation Above-ground Wall': fnd_walls_above, 'Adjacent Attic Wall': adj_attic_walls, - 'Attic Garage Wall': attic_gar_walls, 'Adjacent Garage Wall': adj_gar_walls, 'Adjacent Foundation Wall': adj_fnd_wall, 'Attic Floor': ceilings, @@ -535,30 +533,20 @@ def parse_hpxml_zones(hpxml, boundaries, construction): attic = attics[0] # Get gable wall areas for attic and (possibly) garage - if boundaries.get('Attic Garage Wall', {}).get('Area (m^2)') is not None: #TODO: for now, if we see walls between attic and garage, calculated geometry differently. May be neglecting some heat transfer between garage/attic boundary - attic_wall_areas = (boundaries.get('Attic Wall', {}).get('Area (m^2)', []) + - boundaries.get('Adjacent Attic Wall', {}).get('Area (m^2)', []) + - boundaries.get('Attic Garage Wall', {}).get('Area (m^2)', [])) - attic_gable_area = max(attic_wall_areas[0],attic_wall_areas[1]) #Note: if garage in on the same end as gable wall, area[0] != area[1] + attic_wall_areas = (boundaries.get('Attic Wall', {}).get('Area (m^2)', []) + + boundaries.get('Adjacent Attic Wall', {}).get('Area (m^2)', [])) + if len(attic_wall_areas) == 2: + # standard gable roof with attic + assert abs(attic_wall_areas[1] - attic_wall_areas[0]) < 0.2 # computational errors possible + attic_gable_area = attic_wall_areas[0] third_gable_area = 0 - - del boundaries['Attic Garage Wall'] #FIXME: we need to be able to handle this at some point + elif has_garage and len(attic_wall_areas) == 3: + # 2 attic gables plus 1 garage gable, garage gable has area that is 'more different' + attic_gable_area = attic_wall_areas[1] + low, med, high = tuple(sorted(attic_wall_areas)) + third_gable_area = low if med - low > high - med else high else: - attic_wall_areas = (boundaries.get('Attic Wall', {}).get('Area (m^2)', []) + - boundaries.get('Adjacent Attic Wall', {}).get('Area (m^2)', [])) - - if len(attic_wall_areas) == 2: - # standard gable roof with attic - assert abs(attic_wall_areas[1] - attic_wall_areas[0]) < 0.2 # computational errors possible - attic_gable_area = attic_wall_areas[0] - third_gable_area = 0 - elif has_garage and len(attic_wall_areas) == 3: - # 2 attic gables plus 1 garage gable, garage gable has area that is 'more different' - attic_gable_area = attic_wall_areas[1] - low, med, high = tuple(sorted(attic_wall_areas)) - third_gable_area = low if med - low > high - med else high - else: - raise OCHREException('Unable to calculate attic area, likely an issue with gable walls.') + raise OCHREException('Unable to calculate attic area, likely an issue with gable walls.') # Get attic properties # tan(roof_tilt) = height / (width / 2) @@ -859,6 +847,37 @@ def parse_hvac(hvac_type, hvac_all): 'Weekend Setpoints (C)': [convert(weekend_setpoint, 'degF', 'degC')] * 24, }) + + if has_heat_pump: + if heat_pump.get("CoolingDetailedPerformanceData") is not None: + cooling_detailed_performance_data = heat_pump.get("CoolingDetailedPerformanceData") + cooling_performance = {} + for n in range(len(cooling_detailed_performance_data)): + if cooling_detailed_performance_data[n]["Efficiency"]["Units"] != "COP": + print("non cop efficiency units") # FIXME: This should probably crash the sim. Unlikely we'll ever get something other than COP, but it's what we're expecting + raise OCHREException("Efficiency units not COP") #not sure format of this + if (cooling_detailed_performance_data[n]['OutdoorTemperature']) not in cooling_performance.keys(): + cooling_performance[cooling_detailed_performance_data[n]['OutdoorTemperature']] = {} + + cooling_performance[cooling_detailed_performance_data[n]['OutdoorTemperature']][f"{cooling_detailed_performance_data[n]['CapacityDescription']}_capacity"] = [cooling_detailed_performance_data[n]['Capacity']] + cooling_performance[cooling_detailed_performance_data[n]['OutdoorTemperature']][f"{cooling_detailed_performance_data[n]['CapacityDescription']}_COP"] = [f"{cooling_detailed_performance_data[n]['Efficiency']['Value']}"] + out['CoolingDetailedPerformance'] = cooling_performance + + #TODO: else? Right now we'd keep things as is, but we might want to change default to match changes Yueyue made in OS-HPXML + if heat_pump.get("HeatingDetailedPerformanceData") is not None: + heating_detailed_performance_data = heat_pump.get("HeatingDetailedPerformanceData") + heating_performance = {} + for n in range(len(cooling_detailed_performance_data)): + if heating_detailed_performance_data[n]["Efficiency"]["Units"] != "COP": + print("non cop efficiency units") # FIXME: This should probably crash the sim. Unlikely we'll ever get something other than COP, but it's what we're expecting + raise OCHREException("Efficiency units not COP") + if (heating_detailed_performance_data[n]['OutdoorTemperature']) not in heating_performance.keys(): + heating_performance[heating_detailed_performance_data[n]['OutdoorTemperature']] = {} + + heating_performance[heating_detailed_performance_data[n]['OutdoorTemperature']][f"{heating_detailed_performance_data[n]['CapacityDescription']}_capacity"] = [heating_detailed_performance_data[n]['Capacity']] + heating_performance[heating_detailed_performance_data[n]['OutdoorTemperature']][f"{heating_detailed_performance_data[n]['CapacityDescription']}_COP"] = [f"{heating_detailed_performance_data[n]['Efficiency']['Value']}"] + out['HeatingDetailedPerformance'] = heating_performance + if has_heat_pump and hvac_type == 'Heating': backup_capacity = heat_pump.get('BackupHeatingCapacity', 0) backup_fuel = heat_pump.get('BackupSystemFuel') @@ -879,18 +898,15 @@ def parse_hvac(hvac_type, hvac_all): }) # Get duct info for calculating DSE - distribution = hvac_all.get('HVACDistribution', {}) distribution_type = distribution.get('DistributionSystemType', {}) air_distribution = distribution_type.get('AirDistribution', {}) duct_leakage = air_distribution.get('DuctLeakageMeasurement') - ducts = air_distribution.get('Ducts', []) - if isinstance(ducts, dict): - ducts = list(ducts.values()) - ducts = [d for d in ducts if parse_zone_name(d.get("DuctLocation")) not in ["Indoor", None]] + ducts = [d for d in air_distribution.get('Ducts', {}).values() + if parse_zone_name(d.get('DuctLocation')) not in ['Indoor', None]] if f'Annual{hvac_type}DistributionSystemEfficiency' in distribution: - # Note, ducts are assumed to be in ambient space, DSE losses aren't added to another zone + # Note, ducts are assumed to be in ambient space, DSE l=osses aren't added to another zone out['Ducts'] = { 'DSE (-)': distribution[f'Annual{hvac_type}DistributionSystemEfficiency'], 'Zone': None, @@ -919,6 +935,113 @@ def parse_hvac(hvac_type, hvac_all): return out +def calculate_odb_at_zero_cop_or_capacity(detailed_performance_data, user_odbs, property, find_high, min_cop_or_capacity=0): + # allow it to calculate min temp where cop is at a defined value (less than backup efficiency) (rn set to default of 1) + + if find_high: + if user_odbs[-1] in detailed_performance_data: + odb_1 = user_odbs[-1] # temperature + odb_dp1 = detailed_performance_data[user_odbs[-1]] # data + else: + odb_dp1 = None # insufficient input ? + if user_odbs[-2] in detailed_performance_data: + odb_2 = user_odbs[-2] + odb_dp2 = detailed_performance_data[user_odbs[-2]] + else: + odb_dp2 = None # insufficient input ? + else: + if user_odbs[0] in detailed_performance_data: + odb_1 = user_odbs[0] + odb_dp1 = detailed_performance_data[user_odbs[0]] + else: + odb_dp1 = None # insufficient input ? + if user_odbs[1] in detailed_performance_data: + odb_12 = user_odbs[1] + odb_dp2 = detailed_performance_data[user_odbs[1]] + else: + odb_dp2 = None # insufficient input ? + + slope = (odb_dp1[property] - odb_dp2[property]) / (odb_1 - odb_2) # syntax ? + + # if (find_high & slope >= 0): + # return 999999.0 + # elif (not find_high & slope <= 0): + # return -999999.0 + + # solve for intercept + intercept = odb_dp2[property] - slope*odb_2 + # find target odb + target_odb = (min_cop_or_capacity-intercept)/slope + # add small buffer + delta_odb = 1.0 + if find_high: + return target_odb - delta_odb + else: + return target_odb + delta_odb + +def interpolate_to_odb_table_point(detailed_performance_data, property, target_odb): # removed capacity description as an input our property/capacity description are together (max cop / min capacity) + if target_odb in detailed_performance_data.keys(): + return detailed_performance_data[target_odb][property] + + user_odbs = sorted(set(list(detailed_performance_data.keys()))) + if max(user_odbs) < target_odb: + right_odb = user_odbs[-1] + left_odb = user_odbs[-2] + elif (min(user_odbs) > target_odb): + right_odb = user_odbs[1] + left_odb = user_odbs[0] + else: + right_odb = max(user_odbs) + left_odb = min(user_odbs) + + slope = (detailed_performance_data[right_odb][property] - detailed_performance_data[left_odb][property]) / (right_odb - left_odb) + val = (target_odb - left_odb) * slope + detailed_performance_data[left_odb][property] + + return val + +def interpolate_to_odb_table_points(detailed_performance_data, mode, compressor_lockout_temp, weather_temp): + user_odbs = list(detailed_performance_data.keys()) + + # can do this or just code the variable names into the high/low odb at zero cop/capacity variables , TODO: update with gross cop/capacity + properties = list(detailed_performance_data[list(detailed_performance_data.keys())[0]].keys()) + for property in properties: + if "min" in properties[property] and "cop" in properties[property]: + low_cop = property + elif "max" in properties[property] and "cop" in properties[property]: + high_cop = property + elif "min" in properties[property] and "cap" in properties[property]: + low_capacity = property + elif "max" in properties[property] and "cap" in properties[property]: + high_capacity = property + + # calculate zero COP/capacity odb + if mode=="cooling": + outdoor_dry_bulbs = [] + # TODO: update with gross cop/capacity + high_odb_at_zero_cop = calculate_odb_at_zero_cop_or_capacity(detailed_performance_data, user_odbs, high_cop, True) + high_odb_at_zero_capacity = calculate_odb_at_zero_cop_or_capacity(detailed_performance_data, user_odbs, high_capacity, True) + low_odb_at_zero_cop = calculate_odb_at_zero_cop_or_capacity(detailed_performance_data, user_odbs, low_cop, False) + low_odb_at_zero_capacity = calculate_odb_at_zero_cop_or_capacity(detailed_performance_data, user_odbs, low_capacity, False) + + outdoor_dry_bulbs += [max(low_odb_at_zero_cop, low_odb_at_zero_capacity, 55)] # min cooling odb + outdoor_dry_bulbs += [min(high_odb_at_zero_cop, high_odb_at_zero_capacity, weather_temp)] # max cooling odb + else: + outdoor_dry_bulbs = [] + # TODO: update with gross cop/capacity + high_odb_at_zero_cop = calculate_odb_at_zero_cop_or_capacity(detailed_performance_data, user_odbs, high_cop, False) + high_odb_at_zero_capacity = calculate_odb_at_zero_cop_or_capacity(detailed_performance_data, user_odbs, high_capacity, False) + low_odb_at_zero_cop = calculate_odb_at_zero_cop_or_capacity(detailed_performance_data, user_odbs, low_cop, True) + low_odb_at_zero_capacity = calculate_odb_at_zero_cop_or_capacity(detailed_performance_data, user_odbs, low_capacity, True) + + outdoor_dry_bulbs += [max(low_odb_at_zero_cop, low_odb_at_zero_capacity, compressor_lockout_temp, weather_temp)] # min heating odb + outdoor_dry_bulbs += [min(high_odb_at_zero_cop, high_odb_at_zero_capacity, 60)] # max heating odb + + for target_odb in outdoor_dry_bulbs: + if target_odb not in user_odbs: + #make new sub dictionary for this in the detailed performance data dictionary + detailed_performance_data[target_odb] = {} + for property in properties: # TODO: update with gross cop/capacity + detailed_performance_data[target_odb][property] = interpolate_to_odb_table_point(detailed_performance_data, property, target_odb) def parse_water_heater(water_heater, water, construction, solar_fraction=0): # Returns a dictionary of calculated water heater/water tank properties From 19ed1b110dd43a2f4e9d0f62491d302768ec1dc7 Mon Sep 17 00:00:00 2001 From: Yueyue Zhou Date: Wed, 31 Dec 2025 17:00:24 -0700 Subject: [PATCH 02/14] find the closest from table instead of exact match --- ochre/Equipment/HVAC.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/ochre/Equipment/HVAC.py b/ochre/Equipment/HVAC.py index 192dc343..e97825fd 100644 --- a/ochre/Equipment/HVAC.py +++ b/ochre/Equipment/HVAC.py @@ -1,6 +1,8 @@ import datetime as dt import numpy as np import psychrolib +import re +import pandas as pd from ochre.utils import OCHREException, convert, load_csv from ochre.utils.units import kwh_to_therms @@ -700,9 +702,16 @@ def __init__(self, control_type='Time', **kwargs): rated_efficiency = kwargs.get('Rated Efficiency', '(Unknown Efficiency)') multispeed_file = kwargs.get('multispeed_file', 'HVAC Multispeed Parameters.csv') df_speed = load_csv(multispeed_file) - speed_params = df_speed.loc[(df_speed['HVAC Name'] == self.name) & - (df_speed['HVAC Efficiency'] == rated_efficiency) & + # Convert string to efficiency numbers, find the closest match + numeric_pattern = r"([-+]?(?:\d*\.?\d+))" + rated_efficiency_float = float(re.search(numeric_pattern, rated_efficiency).group()) + df_speed['Temp_Efficiency_Float'] = pd.to_numeric( + df_speed['HVAC Efficiency'].str.extract(numeric_pattern)[0]) + speed_params_subset = df_speed.loc[(df_speed['HVAC Name'] == self.name) & (df_speed['Number of Speeds'] == self.n_speeds)] + closest_match_index = (speed_params_subset['Temp_Efficiency_Float'] - rated_efficiency_float).abs().idxmin() + df_speed = df_speed.drop(columns=['Temp_Efficiency_Float']) + speed_params = df_speed.loc[[closest_match_index]] if not len(speed_params): raise OCHREException(f'Cannot find multispeed parameters for {self.n_speeds}-speed {rated_efficiency} {self.name}') assert len(speed_params) == 1 From 9c0496386b336d2b3628a896555f4b7086cd0cb7 Mon Sep 17 00:00:00 2001 From: Yueyue Zhou Date: Fri, 2 Jan 2026 17:46:50 -0700 Subject: [PATCH 03/14] process efficiency, refactor of detailed performance data processing, cooling detailed performance data default, run black formatting --- ochre/utils/hpxml.py | 2355 +++++++++++++++++++++++++++--------------- 1 file changed, 1500 insertions(+), 855 deletions(-) diff --git a/ochre/utils/hpxml.py b/ochre/utils/hpxml.py index abbceb8b..31590854 100644 --- a/ochre/utils/hpxml.py +++ b/ochre/utils/hpxml.py @@ -1,5 +1,6 @@ import math import pandas as pd +import bisect from ochre.utils import OCHREException, convert, nested_update, import_hpxml from ochre.utils.units import pitch2deg @@ -10,32 +11,39 @@ ZONE_NAME_OPTIONS = { - 'Indoor': ['conditioned space'], - 'Foundation': ['crawlspace', 'basement', 'finishedbasement', 'basement - conditioned', 'basement - unconditioned', - 'crawlspace - vented', 'crawlspace - unvented'], - 'Garage': ['garage'], - 'Attic': ['unfinishedattic', 'attic - vented', 'attic - unvented'], - 'Outdoor': ['exterior', 'outside', 'other exterior'], - 'Ground': ['ground'], - 'Adjacent': ['other'], + "Indoor": ["conditioned space"], + "Foundation": [ + "crawlspace", + "basement", + "finishedbasement", + "basement - conditioned", + "basement - unconditioned", + "crawlspace - vented", + "crawlspace - unvented", + ], + "Garage": ["garage"], + "Attic": ["unfinishedattic", "attic - vented", "attic - unvented"], + "Outdoor": ["exterior", "outside", "other exterior"], + "Ground": ["ground"], + "Adjacent": ["other"], } # fraction of conditioned floor area used for furniture area ZONE_FURNITURE_AREA_FRACTIONS = { - 'Indoor': 0.4, - 'Foundation': 0.4, - 'Garage': 0.1, - 'Attic': 0, # Not adding Attic Furniture + "Indoor": 0.4, + "Foundation": 0.4, + "Garage": 0.1, + "Attic": 0, # Not adding Attic Furniture } MEL_NAMES = { - 'TV other': 'TV', - 'other': 'MELs', - 'well pump': 'Well Pump', - 'electric vehicle charging': 'Electric Vehicle', # gets converted to EV equipment - 'grill': 'Gas Grill', - 'fireplace': 'Gas Fireplace', - 'lighting': 'Gas Lighting', + "TV other": "TV", + "other": "MELs", + "well pump": "Well Pump", + "electric vehicle charging": "Electric Vehicle", # gets converted to EV equipment + "grill": "Gas Grill", + "fireplace": "Gas Fireplace", + "lighting": "Gas Lighting", } # FUTURE: update roughness by material @@ -68,8 +76,8 @@ def parse_zone_name(hpxml_name): return ochre_name # Note, multifamily zones (e.g. 'other housing unit') all include 'other' in the name - if 'other' in hpxml_name: - return 'Adjacent' + if "other" in hpxml_name: + return "Adjacent" if hpxml_name is not None: print(f'WARNING: Cannot parse zone name "{hpxml_name}". Setting zone to None.') @@ -77,23 +85,25 @@ def parse_zone_name(hpxml_name): return None -def get_boundaries_by_zones(boundaries, default_int_zone='Indoor', default_ext_zone='Outdoor'): +def get_boundaries_by_zones( + boundaries, default_int_zone="Indoor", default_ext_zone="Outdoor" +): bd_by_zones = {} for name, boundary in boundaries.items(): - interior = parse_zone_name(boundary.get('InteriorAdjacentTo')) - exterior = parse_zone_name(boundary.get('ExteriorAdjacentTo')) + interior = parse_zone_name(boundary.get("InteriorAdjacentTo")) + exterior = parse_zone_name(boundary.get("ExteriorAdjacentTo")) if interior is None: interior = default_int_zone if exterior is None: exterior = default_ext_zone - elif exterior == 'Adjacent': + elif exterior == "Adjacent": exterior = interior zones = (interior, exterior) if zones not in bd_by_zones: bd_by_zones[zones] = {} - boundary['Interior Zone'] = interior - boundary['Exterior Zone'] = exterior + boundary["Interior Zone"] = interior + boundary["Exterior Zone"] = exterior bd_by_zones[zones][name] = boundary return bd_by_zones @@ -103,92 +113,115 @@ def get_boundaries_by_wall(boundaries, ext_walls, gar_walls, attic_walls, adj_wa # for windows and doors, determine interior zone based on attached wall zone ext_bd, gar_bd, attic_bd = {}, {}, {} for name, boundary in boundaries.items(): - boundary['Exterior Zone'] = 'Outdoor' - wall = boundary['AttachedToWall']['@idref'] + boundary["Exterior Zone"] = "Outdoor" + wall = boundary["AttachedToWall"]["@idref"] if wall in ext_walls: - boundary['Interior Zone'] = 'Indoor' + boundary["Interior Zone"] = "Indoor" ext_bd[name] = boundary - ext_walls[wall]['Area'] -= boundary['Area'] + ext_walls[wall]["Area"] -= boundary["Area"] elif wall in gar_walls: - boundary['Interior Zone'] = 'Garage' + boundary["Interior Zone"] = "Garage" gar_bd[name] = boundary - gar_walls[wall]['Area'] -= boundary['Area'] + gar_walls[wall]["Area"] -= boundary["Area"] elif wall in attic_walls: - boundary['Interior Zone'] = 'Attic' + boundary["Interior Zone"] = "Attic" attic_bd[name] = boundary - attic_walls[wall]['Area'] -= boundary['Area'] + attic_walls[wall]["Area"] -= boundary["Area"] elif wall in adj_walls: # skip doors on adjacent (adiabatic) walls pass else: - raise OCHREException(f'Unknown attached wall for {name}: {wall}') - + raise OCHREException(f"Unknown attached wall for {name}: {wall}") + return ext_bd, gar_bd, attic_bd + def parse_hpxml_surface(bd_name, bd_data): out = { - 'Interior Zone': bd_data['Interior Zone'], - 'Exterior Zone': bd_data['Exterior Zone'], - 'Area (m^2)': convert(bd_data['Area'], 'ft^2', 'm^2'), + "Interior Zone": bd_data["Interior Zone"], + "Exterior Zone": bd_data["Exterior Zone"], + "Area (m^2)": convert(bd_data["Area"], "ft^2", "m^2"), } # Add azimuth if it exists - azimuth = bd_data.get('Azimuth') + azimuth = bd_data.get("Azimuth") if azimuth is not None: - out['Azimuth (deg)'] = azimuth + out["Azimuth (deg)"] = azimuth # Add R value if it exists - r_value = bd_data.get('Insulation', {}).get('AssemblyEffectiveRValue', bd_data.get('RValue')) + r_value = bd_data.get("Insulation", {}).get( + "AssemblyEffectiveRValue", bd_data.get("RValue") + ) if r_value: - out['Boundary R Value'] = r_value + out["Boundary R Value"] = r_value # Exterior boundary properties (roofs, walls, and rim joists). Not used for raised floors - if bd_data.get('Exterior Zone') == 'Outdoor': - if 'SolarAbsorptance' in bd_data: - out['Exterior Solar Absorptivity (-)'] = bd_data['SolarAbsorptance'] - if 'Emittance' in bd_data: - out['Exterior Emissivity (-)'] = bd_data['Emittance'] + if bd_data.get("Exterior Zone") == "Outdoor": + if "SolarAbsorptance" in bd_data: + out["Exterior Solar Absorptivity (-)"] = bd_data["SolarAbsorptance"] + if "Emittance" in bd_data: + out["Exterior Emissivity (-)"] = bd_data["Emittance"] # Boundary-specific properties - if 'Roof' in bd_name: - construction_type = 'Pitched' if bd_data.get('Pitch') > 0 else 'Flat' - if bd_data.get('RadiantBarrier'): - construction_type += ', Radiant Barrier' - out.update({ - 'Finish Type': bd_data.get('RoofType'), - 'Construction Type': construction_type, - 'Tilt (deg)': pitch2deg(bd_data.get('Pitch')), - 'Radiant Barrier': bd_data.get('RadiantBarrier', False), - }) - elif 'Floor' in bd_name and bd_data.get('Exterior Zone') == 'Ground': - out.update({ - 'Insulation Details': utils_envelope.get_slab_insulation(bd_data, bd_name), - }) - elif bd_name == 'Foundation Wall': - out.update({ - 'Insulation Details': utils_envelope.get_fnd_wall_insulation(bd_data), - 'Height (m)': convert(bd_data.get('Height'), 'ft', 'm'), - }) - elif 'Wall' in bd_name and bd_data.get('Exterior Zone') == 'Outdoor': + if "Roof" in bd_name: + construction_type = "Pitched" if bd_data.get("Pitch") > 0 else "Flat" + if bd_data.get("RadiantBarrier"): + construction_type += ", Radiant Barrier" + out.update( + { + "Finish Type": bd_data.get("RoofType"), + "Construction Type": construction_type, + "Tilt (deg)": pitch2deg(bd_data.get("Pitch")), + "Radiant Barrier": bd_data.get("RadiantBarrier", False), + } + ) + elif "Floor" in bd_name and bd_data.get("Exterior Zone") == "Ground": + out.update( + { + "Insulation Details": utils_envelope.get_slab_insulation( + bd_data, bd_name + ), + } + ) + elif bd_name == "Foundation Wall": + out.update( + { + "Insulation Details": utils_envelope.get_fnd_wall_insulation(bd_data), + "Height (m)": convert(bd_data.get("Height"), "ft", "m"), + } + ) + elif "Wall" in bd_name and bd_data.get("Exterior Zone") == "Outdoor": # not used for all walls - out.update({ - 'Finish Type': bd_data.get('Siding'), - 'Construction Type': bd_data.get('WallType'), - }) - elif bd_name in ['Adjacent Wall', 'Garage Attached Wall']: - out.update({ - 'Construction Type': bd_data.get('WallType'), - }) - elif bd_name == 'Rim Joist': - out.update({ - 'Finish Type': bd_data.get('Siding'), - }) - elif bd_name == 'Window': - out.update({ - 'U Factor (W/m^2-K)': bd_data.get('UFactor',), - 'SHGC (-)': bd_data.get('SHGC'), - 'Shading Fraction (-)': bd_data.get('InteriorShading', {}).get('SummerShadingCoefficient'), - }) + out.update( + { + "Finish Type": bd_data.get("Siding"), + "Construction Type": bd_data.get("WallType"), + } + ) + elif bd_name in ["Adjacent Wall", "Garage Attached Wall"]: + out.update( + { + "Construction Type": bd_data.get("WallType"), + } + ) + elif bd_name == "Rim Joist": + out.update( + { + "Finish Type": bd_data.get("Siding"), + } + ) + elif bd_name == "Window": + out.update( + { + "U Factor (W/m^2-K)": bd_data.get( + "UFactor", + ), + "SHGC (-)": bd_data.get("SHGC"), + "Shading Fraction (-)": bd_data.get("InteriorShading", {}).get( + "SummerShadingCoefficient" + ), + } + ) return out @@ -200,179 +233,205 @@ def parse_hpxml_boundary(bd_name, bd_data): # combine boundary data. keep area and azimuth lists, and check that all other parameters are equal out = bd_data[0] for key, val in out.items(): - if key in ['Area (m^2)', 'Azimuth (deg)'] and val is not None: + if key in ["Area (m^2)", "Azimuth (deg)"] and val is not None: out[key] = [bd[key] for bd in bd_data] else: if not all([bd[key] == val for bd in bd_data]): - raise OCHREException(f'{bd_name} {key} values in HPXML are not all equal: {[bd[key] for bd in bd_data]}') + raise OCHREException( + f"{bd_name} {key} values in HPXML are not all equal: {[bd[key] for bd in bd_data]}" + ) # Consolidate areas and azimuths (e.g. if multiple windows per wall) - if 'Area (m^2)' in out and out.get('Azimuth (deg)') is not None: - areas, azs = out['Area (m^2)'], out['Azimuth (deg)'] + if "Area (m^2)" in out and out.get("Azimuth (deg)") is not None: + areas, azs = out["Area (m^2)"], out["Azimuth (deg)"] az_unique = set(azs) if len(az_unique) < len(azs): - out['Area (m^2)'] = [sum([area for area, az in zip(areas, azs) if az == az2]) for az2 in az_unique] - out['Azimuth (deg)'] = list(az_unique) + out["Area (m^2)"] = [ + sum([area for area, az in zip(areas, azs) if az == az2]) + for az2 in az_unique + ] + out["Azimuth (deg)"] = list(az_unique) return out def parse_hpxml_boundaries(hpxml, return_boundary_dicts=False, **kwargs): - enclosure = hpxml['Enclosure'] - construction = hpxml['BuildingSummary']['BuildingConstruction'] + enclosure = hpxml["Enclosure"] + construction = hpxml["BuildingSummary"]["BuildingConstruction"] # Get variables for calculating zone volumes, check geometry assumptions - conditioned_floor_area = convert(construction['ConditionedFloorArea'], 'ft^2', 'm^2') # indoor + foundation - conditioned_volume = convert(construction['ConditionedBuildingVolume'], 'ft^3', 'm^3') + conditioned_floor_area = convert( + construction["ConditionedFloorArea"], "ft^2", "m^2" + ) # indoor + foundation + conditioned_volume = convert( + construction["ConditionedBuildingVolume"], "ft^3", "m^3" + ) # aspect_ratio = kwargs.get('aspect ratio', 1.8) # assumes length (sides of house) is shorter than width # house_length = (floor_area / aspect_ratio) ** 0.5 # house_width = house_length * aspect_ratio ceiling_height = conditioned_volume / conditioned_floor_area - if 'AverageCeilingHeight' in construction: - assert abs(convert(construction['AverageCeilingHeight'], 'ft', 'm') - ceiling_height) < 0.1 + if "AverageCeilingHeight" in construction: + assert ( + abs( + convert(construction["AverageCeilingHeight"], "ft", "m") + - ceiling_height + ) + < 0.1 + ) # Get number of bedrooms and bathrooms - n_beds = construction['NumberofBedrooms'] - n_baths = construction.get('NumberofBathrooms', n_beds / 2 + 0.5) + n_beds = construction["NumberofBedrooms"] + n_baths = construction.get("NumberofBathrooms", n_beds / 2 + 0.5) # Get foundation type # TODO: if fnd wall insulation doesn't depend on foundation type, move this to parse_hpxml_zones - total_floors = construction['NumberofConditionedFloors'] - indoor_floors = construction.get('NumberofConditionedFloorsAboveGrade', total_floors) + total_floors = construction["NumberofConditionedFloors"] + indoor_floors = construction.get( + "NumberofConditionedFloorsAboveGrade", total_floors + ) assert indoor_floors >= 1 - foundations = list(enclosure.get('Foundations', {}).values()) + foundations = list(enclosure.get("Foundations", {}).values()) if not foundations: foundation = None foundation_name = None else: assert len(foundations) == 1 foundation = foundations[0] - foundation_type = foundation.get('FoundationType') + foundation_type = foundation.get("FoundationType") if isinstance(foundation_type, dict): foundation_name = list(foundation_type.keys())[0] # Crawlspace or Basement - if foundation_name == 'Basement': + if foundation_name == "Basement": if total_floors > indoor_floors: - foundation_name = 'Finished Basement' + foundation_name = "Finished Basement" else: - foundation_name = 'Unfinished Basement' - elif foundation_type in ['SlabOnGrade', 'Ambient', 'AboveApartment', None]: + foundation_name = "Unfinished Basement" + elif foundation_type in ["SlabOnGrade", "Ambient", "AboveApartment", None]: # no foundation zone for slab or pier+beam foundations foundation_name = None else: - raise OCHREException(f'Unknown foundation type: {foundation_type}') + raise OCHREException(f"Unknown foundation type: {foundation_type}") construction_dict = { - 'Number of Bedrooms (-)': n_beds, - 'Number of Bathrooms (-)': n_baths, - 'House Type': construction['ResidentialFacilityType'], - 'Total Floors': total_floors, - 'Indoor Floors': indoor_floors, - 'Conditioned Floor Area (m^2)': conditioned_floor_area, - 'Conditioned Volume (m^3)': conditioned_volume, - 'Ceiling Height (m)': ceiling_height, - 'Foundation Type': foundation_name, + "Number of Bedrooms (-)": n_beds, + "Number of Bathrooms (-)": n_baths, + "House Type": construction["ResidentialFacilityType"], + "Total Floors": total_floors, + "Indoor Floors": indoor_floors, + "Conditioned Floor Area (m^2)": conditioned_floor_area, + "Conditioned Volume (m^3)": conditioned_volume, + "Ceiling Height (m)": ceiling_height, + "Foundation Type": foundation_name, } # Get all walls - all_walls = get_boundaries_by_zones(enclosure.get('Walls', {})) - ext_walls = all_walls.pop(('Indoor', 'Outdoor'), {}) - attic_walls = all_walls.pop(('Attic', 'Outdoor'), {}) - gar_walls = all_walls.pop(('Garage', 'Outdoor'), {}) - attached_walls = all_walls.pop(('Indoor', 'Garage'), {}) - adj_walls = all_walls.pop(('Indoor', 'Indoor'), {}) - adj_attic_walls = all_walls.pop(('Attic', 'Attic'), {}) - adj_gar_walls = all_walls.pop(('Garage', 'Garage'), {}) + all_walls = get_boundaries_by_zones(enclosure.get("Walls", {})) + ext_walls = all_walls.pop(("Indoor", "Outdoor"), {}) + attic_walls = all_walls.pop(("Attic", "Outdoor"), {}) + gar_walls = all_walls.pop(("Garage", "Outdoor"), {}) + attached_walls = all_walls.pop(("Indoor", "Garage"), {}) + adj_walls = all_walls.pop(("Indoor", "Indoor"), {}) + adj_attic_walls = all_walls.pop(("Attic", "Attic"), {}) + adj_gar_walls = all_walls.pop(("Garage", "Garage"), {}) assert not all_walls # verifies that all boundaries are accounted for # Get foundation walls - all_fnd_walls = get_boundaries_by_zones(enclosure.get('FoundationWalls', {})) - fnd_walls = all_fnd_walls.pop(('Foundation', 'Ground'), {}) + all_fnd_walls = get_boundaries_by_zones(enclosure.get("FoundationWalls", {})) + fnd_walls = all_fnd_walls.pop(("Foundation", "Ground"), {}) # fnd_walls_above = all_fnd_walls.pop(('Foundation', 'Outdoor'), {}) # not used, see rim joist - adj_fnd_wall = all_fnd_walls.pop(('Foundation', 'Foundation'), {}) + adj_fnd_wall = all_fnd_walls.pop(("Foundation", "Foundation"), {}) assert not all_fnd_walls # verifies that all boundaries are accounted for # Get ceilings and non-slab floors, i.e. FrameFloors - all_ceilings = get_boundaries_by_zones({**enclosure.get('Floors', {}), **enclosure.get('FrameFloors', {})}) - ceilings = all_ceilings.pop(('Indoor', 'Attic'), {}) - fnd_ceilings = all_ceilings.pop(('Indoor', 'Foundation'), {}) - raised_floors = all_ceilings.pop(('Indoor', 'Outdoor'), {}) - garage_ceilings = all_ceilings.pop(('Garage', 'Attic'), {}) - garage_ceilings_int = all_ceilings.pop(('Indoor', 'Garage'), {}) - adj_ceilings = all_ceilings.pop(('Indoor', 'Indoor'), {}) + all_ceilings = get_boundaries_by_zones( + {**enclosure.get("Floors", {}), **enclosure.get("FrameFloors", {})} + ) + ceilings = all_ceilings.pop(("Indoor", "Attic"), {}) + fnd_ceilings = all_ceilings.pop(("Indoor", "Foundation"), {}) + raised_floors = all_ceilings.pop(("Indoor", "Outdoor"), {}) + garage_ceilings = all_ceilings.pop(("Garage", "Attic"), {}) + garage_ceilings_int = all_ceilings.pop(("Indoor", "Garage"), {}) + adj_ceilings = all_ceilings.pop(("Indoor", "Indoor"), {}) assert not all_ceilings # verifies that all boundaries are accounted for # Separate adjacent floors and ceilings (may have different insulation levels) - adj_floors = {key: val for key, val in adj_ceilings.items() if val.get('FloorOrCeiling') == 'floor'} + adj_floors = { + key: val + for key, val in adj_ceilings.items() + if val.get("FloorOrCeiling") == "floor" + } for key in adj_floors: adj_ceilings.pop(key) # Get floors (slabs in HPXML) - all_slabs = get_boundaries_by_zones(enclosure.get('Slabs', {}), default_ext_zone='Ground') - floors = all_slabs.pop(('Indoor', 'Ground'), {}) - fnd_floors = all_slabs.pop(('Foundation', 'Ground'), {}) - garage_floors = all_slabs.pop(('Garage', 'Ground'), {}) + all_slabs = get_boundaries_by_zones( + enclosure.get("Slabs", {}), default_ext_zone="Ground" + ) + floors = all_slabs.pop(("Indoor", "Ground"), {}) + fnd_floors = all_slabs.pop(("Foundation", "Ground"), {}) + garage_floors = all_slabs.pop(("Garage", "Ground"), {}) assert not all_slabs # verifies that all boundaries are accounted for # Get roofs - all_roofs = get_boundaries_by_zones(enclosure.get('Roofs', {})) - roofs = all_roofs.pop(('Indoor', 'Outdoor'), {}) # Note: not necessarily flat - attic_roofs = all_roofs.pop(('Attic', 'Outdoor'), {}) - garage_roofs = all_roofs.pop(('Garage', 'Outdoor'), {}) + all_roofs = get_boundaries_by_zones(enclosure.get("Roofs", {})) + roofs = all_roofs.pop(("Indoor", "Outdoor"), {}) # Note: not necessarily flat + attic_roofs = all_roofs.pop(("Attic", "Outdoor"), {}) + garage_roofs = all_roofs.pop(("Garage", "Outdoor"), {}) assert not all_roofs # verifies that all boundaries are accounted for # Get rim joists (outdoor to foundation only) - all_rim_joists = get_boundaries_by_zones(enclosure.get('RimJoists', {})) - rim_joists = all_rim_joists.pop(('Foundation', 'Outdoor'), {}) - adj_rim_joists = all_rim_joists.pop(('Foundation', 'Foundation'), {}) + all_rim_joists = get_boundaries_by_zones(enclosure.get("RimJoists", {})) + rim_joists = all_rim_joists.pop(("Foundation", "Outdoor"), {}) + adj_rim_joists = all_rim_joists.pop(("Foundation", "Foundation"), {}) assert not all_rim_joists # verifies that all boundaries are accounted for # Get windows (only accepts windows to indoor zone for now), and subtract wall area - all_windows = enclosure.get('Windows', {}) + all_windows = enclosure.get("Windows", {}) windows, gar_windows, attic_windows = get_boundaries_by_wall( all_windows, ext_walls, gar_walls, attic_walls, adj_walls ) assert not gar_windows assert not attic_windows - + # Get doors (only accepts doors to indoor zone and garage), and subtract wall area - all_doors = enclosure.get('Doors', {}) + all_doors = enclosure.get("Doors", {}) doors, gar_doors, attic_doors = get_boundaries_by_wall( all_doors, ext_walls, gar_walls, attic_walls, adj_walls ) assert not attic_doors boundaries = { - 'Exterior Wall': ext_walls, - 'Attic Wall': attic_walls, - 'Garage Attached Wall': attached_walls, - 'Garage Wall': gar_walls, - 'Adjacent Wall': adj_walls, - 'Foundation Wall': fnd_walls, + "Exterior Wall": ext_walls, + "Attic Wall": attic_walls, + "Garage Attached Wall": attached_walls, + "Garage Wall": gar_walls, + "Adjacent Wall": adj_walls, + "Foundation Wall": fnd_walls, # 'Foundation Above-ground Wall': fnd_walls_above, - 'Adjacent Attic Wall': adj_attic_walls, - 'Adjacent Garage Wall': adj_gar_walls, - 'Adjacent Foundation Wall': adj_fnd_wall, - 'Attic Floor': ceilings, - 'Foundation Ceiling': fnd_ceilings, - 'Garage Ceiling': garage_ceilings, - 'Garage Interior Ceiling': garage_ceilings_int, - 'Adjacent Ceiling': adj_ceilings, - 'Adjacent Floor': adj_floors, - 'Floor': floors, - 'Foundation Floor': fnd_floors, - 'Garage Floor': garage_floors, - 'Raised Floor': raised_floors, - 'Attic Roof': attic_roofs, - 'Roof': roofs, - 'Garage Roof': garage_roofs, - 'Rim Joist': rim_joists, - 'Adjacent Rim Joist': adj_rim_joists, - 'Window': windows, - 'Door': doors, - 'Garage Door': gar_doors, - + "Adjacent Attic Wall": adj_attic_walls, + "Adjacent Garage Wall": adj_gar_walls, + "Adjacent Foundation Wall": adj_fnd_wall, + "Attic Floor": ceilings, + "Foundation Ceiling": fnd_ceilings, + "Garage Ceiling": garage_ceilings, + "Garage Interior Ceiling": garage_ceilings_int, + "Adjacent Ceiling": adj_ceilings, + "Adjacent Floor": adj_floors, + "Floor": floors, + "Foundation Floor": fnd_floors, + "Garage Floor": garage_floors, + "Raised Floor": raised_floors, + "Attic Roof": attic_roofs, + "Roof": roofs, + "Garage Roof": garage_roofs, + "Rim Joist": rim_joists, + "Adjacent Rim Joist": adj_rim_joists, + "Window": windows, + "Door": doors, + "Garage Door": gar_doors, } - boundaries = {key: val for key, val in boundaries.items() if len(val)} # remove empty boundaries + boundaries = { + key: val for key, val in boundaries.items() if len(val) + } # remove empty boundaries if return_boundary_dicts: return boundaries, construction_dict @@ -381,164 +440,247 @@ def parse_hpxml_boundaries(hpxml, return_boundary_dicts=False, **kwargs): boundaries[bd_name] = parse_hpxml_boundary(bd_name, bd_data) # Add Construction Type to foundation walls - used for getting insulation - if 'Foundation Wall' in bd_name: - boundaries[bd_name]['Construction Type'] = foundation_name + if "Foundation Wall" in bd_name: + boundaries[bd_name]["Construction Type"] = foundation_name # Add siding to garage attached walls - taken from exterior walls - if bd_name == 'Garage Attached Wall': - boundaries[bd_name]['Finish Type'] = boundaries['Exterior Wall']['Finish Type'] + if bd_name == "Garage Attached Wall": + boundaries[bd_name]["Finish Type"] = boundaries["Exterior Wall"][ + "Finish Type" + ] # Get main floor area - should have only 1 main floor boundary option - main_floor_options = ['Floor', 'Foundation Floor', 'Raised Floor', 'Adjacent Floor'] - main_floor_areas = [area for floor_option in main_floor_options - for area in boundaries.get(floor_option, {}).get('Area (m^2)', [])] + main_floor_options = ["Floor", "Foundation Floor", "Raised Floor", "Adjacent Floor"] + main_floor_areas = [ + area + for floor_option in main_floor_options + for area in boundaries.get(floor_option, {}).get("Area (m^2)", []) + ] if len(main_floor_areas) == 1: - first_floor_area = main_floor_areas[0] # area of first (lowest above grade) floor. Excludes garage + first_floor_area = main_floor_areas[ + 0 + ] # area of first (lowest above grade) floor. Excludes garage else: - raise OCHREException(f'Unable to parse multiple floor areas: {main_floor_areas}') + raise OCHREException( + f"Unable to parse multiple floor areas: {main_floor_areas}" + ) # Get attic and top floor area - should have only 1 attic floor boundary option (plus maybe 'Garage Ceiling') - top_floor_options = ['Attic Floor', 'Roof', 'Adjacent Ceiling'] - top_floor_areas = [area for floor_option in top_floor_options - for area in boundaries.get(floor_option, {}).get('Area (m^2)', [])] + top_floor_options = ["Attic Floor", "Roof", "Adjacent Ceiling"] + top_floor_areas = [ + area + for floor_option in top_floor_options + for area in boundaries.get(floor_option, {}).get("Area (m^2)", []) + ] if len(top_floor_areas) == 1: - top_floor_area = top_floor_areas[0] # area of first (lowest above grade) floor. Excludes garage + top_floor_area = top_floor_areas[ + 0 + ] # area of first (lowest above grade) floor. Excludes garage else: - raise OCHREException(f'Unable to parse multiple attic floor areas: {top_floor_areas}') - attic_floor_area = top_floor_area + sum(boundaries.get('Garage Ceiling', {}).get('Area (m^2)', [])) + raise OCHREException( + f"Unable to parse multiple attic floor areas: {top_floor_areas}" + ) + attic_floor_area = top_floor_area + sum( + boundaries.get("Garage Ceiling", {}).get("Area (m^2)", []) + ) - if 'Garage Floor' in boundaries: + if "Garage Floor" in boundaries: # get garage area and wall height - garage_floor_area = boundaries['Garage Floor']['Area (m^2)'][0] - garage_wall_ar = (boundaries['Garage Wall']['Area (m^2)'] + - boundaries.get('Adjacent Garage Wall', {}).get('Area (m^2)', [])) - garage_wall_az = (boundaries['Garage Wall']['Azimuth (deg)'] + - boundaries.get('Adjacent Garage Wall', {}).get('Azimuth (deg)', [])) + garage_floor_area = boundaries["Garage Floor"]["Area (m^2)"][0] + garage_wall_ar = boundaries["Garage Wall"]["Area (m^2)"] + boundaries.get( + "Adjacent Garage Wall", {} + ).get("Area (m^2)", []) + garage_wall_az = boundaries["Garage Wall"]["Azimuth (deg)"] + boundaries.get( + "Adjacent Garage Wall", {} + ).get("Azimuth (deg)", []) garage_wall_az = [az % 180 for az in garage_wall_az] - a1, a2 = tuple([max([ar for ar, az in zip(garage_wall_ar, garage_wall_az) - if az == azimuth]) for azimuth in set(garage_wall_az)]) + a1, a2 = tuple( + [ + max( + [ + ar + for ar, az in zip(garage_wall_ar, garage_wall_az) + if az == azimuth + ] + ) + for azimuth in set(garage_wall_az) + ] + ) garage_wall_height = (a1 * a2 / garage_floor_area) ** 0.5 - attached_wall_areas = boundaries['Garage Attached Wall']['Area (m^2)'] + attached_wall_areas = boundaries["Garage Attached Wall"]["Area (m^2)"] n_walls = len(attached_wall_areas) if n_walls == 1: garage_area_in_main = 0 elif n_walls == 2: # usually for 1-story home or garage that is fully under the 2nd story a1, a2 = tuple(attached_wall_areas) - garage_area_in_main = a1 * a2 / garage_wall_height ** 2 + garage_area_in_main = a1 * a2 / garage_wall_height**2 elif n_walls == 3: # usually for 2-story home with protruding garage. 2 regular walls + 1 gable wall - attached_wall_azimuths = [az % 180 for az in boundaries['Garage Attached Wall']['Azimuth (deg)']] - a1, a2 = tuple([max([ar for ar, az in zip(attached_wall_areas, attached_wall_azimuths) - if az == azimuth]) for azimuth in set(attached_wall_azimuths)]) - garage_area_in_main = a1 * a2 / garage_wall_height ** 2 + attached_wall_azimuths = [ + az % 180 for az in boundaries["Garage Attached Wall"]["Azimuth (deg)"] + ] + a1, a2 = tuple( + [ + max( + [ + ar + for ar, az in zip( + attached_wall_areas, attached_wall_azimuths + ) + if az == azimuth + ] + ) + for azimuth in set(attached_wall_azimuths) + ] + ) + garage_area_in_main = a1 * a2 / garage_wall_height**2 else: - raise OCHREException('Invalid geometry. Cannot parse more than 3 garage walls.') - assert 0 <= garage_area_in_main / garage_floor_area < 1.001 # should be close to 50% for ResStock cases + raise OCHREException( + "Invalid geometry. Cannot parse more than 3 garage walls." + ) + assert ( + 0 <= garage_area_in_main / garage_floor_area < 1.001 + ) # should be close to 50% for ResStock cases else: garage_floor_area = 0 garage_area_in_main = 0 - indoor_floor_area = conditioned_floor_area - first_floor_area * (total_floors - indoor_floors) + indoor_floor_area = conditioned_floor_area - first_floor_area * ( + total_floors - indoor_floors + ) indoor_floor_check = first_floor_area + top_floor_area * (indoor_floors - 1) if abs(indoor_floor_check - indoor_floor_area) > 10: - print(f'WARNING: Indoor floor area calculations do not agree: ' - f'{indoor_floor_area} m^2 and {indoor_floor_check} m^2') - construction_dict.update({ - 'First Floor Area (m^2)': first_floor_area, - 'Attic Floor Area (m^2)': attic_floor_area, - 'Garage Floor Area (m^2)': garage_floor_area, - 'Garage Protruded Area (m^2)': garage_floor_area - garage_area_in_main, # area not in main rectangle - 'Indoor Floor Area (m^2)': indoor_floor_area, - }) + print( + f"WARNING: Indoor floor area calculations do not agree: " + f"{indoor_floor_area} m^2 and {indoor_floor_check} m^2" + ) + construction_dict.update( + { + "First Floor Area (m^2)": first_floor_area, + "Attic Floor Area (m^2)": attic_floor_area, + "Garage Floor Area (m^2)": garage_floor_area, + "Garage Protruded Area (m^2)": garage_floor_area + - garage_area_in_main, # area not in main rectangle + "Indoor Floor Area (m^2)": indoor_floor_area, + } + ) return boundaries, construction_dict def parse_indoor_infiltration(hpxml, construction, equipment): # get infiltration data from HPXML - enclosure = hpxml['Enclosure'] - indoor_infiltration = enclosure['AirInfiltration'] - inf = indoor_infiltration['AirInfiltrationMeasurement'] - site = hpxml['BuildingSummary']['Site'] - + enclosure = hpxml["Enclosure"] + indoor_infiltration = enclosure["AirInfiltration"] + inf = indoor_infiltration["AirInfiltrationMeasurement"] + site = hpxml["BuildingSummary"]["Site"] + # Check if house has a flue or chimney - has_flue_or_chimney = indoor_infiltration.get('extension', {}).get('HasFlueOrChimneyInConditionedSpace') + has_flue_or_chimney = indoor_infiltration.get("extension", {}).get( + "HasFlueOrChimneyInConditionedSpace" + ) if has_flue_or_chimney is None: # TODO: equipment has to be in conditioned space (indoor or conditioned basement) - heater = equipment.get('HVAC Heating', {}) - gas_heater = heater.get('Fuel', 'Electricity') != 'Electricity' and (1 / heater.get('EIR (-)', 1)) < 0.89 - wh = equipment.get('Water Heating', {}) - gas_wh = wh.get('Fuel', 'Electricity') != 'Electricity' and wh.get('Energy Factor (-)', 1) < 0.63 + heater = equipment.get("HVAC Heating", {}) + gas_heater = ( + heater.get("Fuel", "Electricity") != "Electricity" + and (1 / heater.get("EIR (-)", 1)) < 0.89 + ) + wh = equipment.get("Water Heating", {}) + gas_wh = ( + wh.get("Fuel", "Electricity") != "Electricity" + and wh.get("Energy Factor (-)", 1) < 0.63 + ) has_flue_or_chimney = gas_heater or gas_wh - return utils_envelope.calculate_ashrae_infiltration_params(inf, construction, site, has_flue_or_chimney) + return utils_envelope.calculate_ashrae_infiltration_params( + inf, construction, site, has_flue_or_chimney + ) def parse_hpxml_zones(hpxml, boundaries, construction): - enclosure = hpxml['Enclosure'] - first_floor_area = construction['First Floor Area (m^2)'] - attic_floor_area = construction['Attic Floor Area (m^2)'] - garage_floor_area = construction['Garage Floor Area (m^2)'] - garage_protruded_area = construction['Garage Protruded Area (m^2)'] - indoor_floor_area = construction['Indoor Floor Area (m^2)'] - ceiling_height = construction['Ceiling Height (m)'] - building_height = ceiling_height * construction['Indoor Floors'] + enclosure = hpxml["Enclosure"] + first_floor_area = construction["First Floor Area (m^2)"] + attic_floor_area = construction["Attic Floor Area (m^2)"] + garage_floor_area = construction["Garage Floor Area (m^2)"] + garage_protruded_area = construction["Garage Protruded Area (m^2)"] + indoor_floor_area = construction["Indoor Floor Area (m^2)"] + ceiling_height = construction["Ceiling Height (m)"] + building_height = ceiling_height * construction["Indoor Floors"] has_garage = garage_floor_area > 0 - if 'Attic Roof' in boundaries: - roof_tilt = convert(boundaries['Attic Roof']['Tilt (deg)'], 'deg', 'rad') + if "Attic Roof" in boundaries: + roof_tilt = convert(boundaries["Attic Roof"]["Tilt (deg)"], "deg", "rad") else: roof_tilt = None - if 'Garage Roof' in boundaries: - garage_tilt = convert(boundaries['Garage Roof']['Tilt (deg)'], 'deg', 'rad') + if "Garage Roof" in boundaries: + garage_tilt = convert(boundaries["Garage Roof"]["Tilt (deg)"], "deg", "rad") else: garage_tilt = roof_tilt # Indoor ventilation parameters - Whole ventilation fans only # Note: Indoor infiltration parameters depend on equipment, added in add_indoor_infiltration - nat_ventilation_params = utils_envelope.calculate_ela_coefficients('Indoor', building_height) + nat_ventilation_params = utils_envelope.calculate_ela_coefficients( + "Indoor", building_height + ) zones = { - 'Indoor': { - 'Zone Area (m^2)': indoor_floor_area, - 'Volume (m^3)': indoor_floor_area * ceiling_height, + "Indoor": { + "Zone Area (m^2)": indoor_floor_area, + "Volume (m^3)": indoor_floor_area * ceiling_height, **nat_ventilation_params, - }} - vent_fans = hpxml['Systems'].get('MechanicalVentilation', {}).get('VentilationFans', {}) - vent_fans = {key: val for key, val in vent_fans.items() if val.get('UsedForWholeBuildingVentilation', False)} + } + } + vent_fans = ( + hpxml["Systems"].get("MechanicalVentilation", {}).get("VentilationFans", {}) + ) + vent_fans = { + key: val + for key, val in vent_fans.items() + if val.get("UsedForWholeBuildingVentilation", False) + } if vent_fans: assert len(vent_fans) == 1 vent_fan = list(vent_fans.values())[0] - fan_type = vent_fan['FanType'] - balanced = fan_type in ['energy recovery ventilator', 'heat recovery ventilator', 'balanced'] - if 'recovery ventilator' in fan_type: - assert 'SensibleRecoveryEfficiency' in vent_fan - sensible_recovery = vent_fan.get('SensibleRecoveryEfficiency', 0) - latent_recovery = vent_fan.get('TotalRecoveryEfficiency', 0) - sensible_recovery - zones['Indoor'].update({ - 'Ventilation Rate (cfm)': vent_fan['RatedFlowRate'], - 'Ventilation Type': fan_type, - 'Balanced Ventilation': balanced, - 'Sensible Recovery Efficiency (-)': sensible_recovery, - 'Latent Recovery Efficiency (-)': latent_recovery, - }) + fan_type = vent_fan["FanType"] + balanced = fan_type in [ + "energy recovery ventilator", + "heat recovery ventilator", + "balanced", + ] + if "recovery ventilator" in fan_type: + assert "SensibleRecoveryEfficiency" in vent_fan + sensible_recovery = vent_fan.get("SensibleRecoveryEfficiency", 0) + latent_recovery = vent_fan.get("TotalRecoveryEfficiency", 0) - sensible_recovery + zones["Indoor"].update( + { + "Ventilation Rate (cfm)": vent_fan["RatedFlowRate"], + "Ventilation Type": fan_type, + "Balanced Ventilation": balanced, + "Sensible Recovery Efficiency (-)": sensible_recovery, + "Latent Recovery Efficiency (-)": latent_recovery, + } + ) # Add Attic Zone # Note: pitched roofs connected to Indoor zone have a conditioned attic in HPXML, not using 'Attics' # attics = [a for a in enclosure.get('Attics', {}).values() # if a.get('AtticType') not in ['FlatRoof', 'BelowApartment', None]] - if 'Attic Roof' in boundaries: - attics = list(enclosure.get('Attics', {}).values()) + if "Attic Roof" in boundaries: + attics = list(enclosure.get("Attics", {}).values()) assert len(attics) == 1 attic = attics[0] # Get gable wall areas for attic and (possibly) garage - attic_wall_areas = (boundaries.get('Attic Wall', {}).get('Area (m^2)', []) + - boundaries.get('Adjacent Attic Wall', {}).get('Area (m^2)', [])) + attic_wall_areas = boundaries.get("Attic Wall", {}).get( + "Area (m^2)", [] + ) + boundaries.get("Adjacent Attic Wall", {}).get("Area (m^2)", []) if len(attic_wall_areas) == 2: # standard gable roof with attic - assert abs(attic_wall_areas[1] - attic_wall_areas[0]) < 0.2 # computational errors possible + assert ( + abs(attic_wall_areas[1] - attic_wall_areas[0]) < 0.2 + ) # computational errors possible attic_gable_area = attic_wall_areas[0] third_gable_area = 0 elif has_garage and len(attic_wall_areas) == 3: @@ -547,7 +689,9 @@ def parse_hpxml_zones(hpxml, boundaries, construction): low, med, high = tuple(sorted(attic_wall_areas)) third_gable_area = low if med - low > high - med else high else: - raise OCHREException('Unable to calculate attic area, likely an issue with gable walls.') + raise OCHREException( + "Unable to calculate attic area, likely an issue with gable walls." + ) # Get attic properties # tan(roof_tilt) = height / (width / 2) @@ -558,143 +702,176 @@ def parse_hpxml_zones(hpxml, boundaries, construction): garage_height = (third_gable_area * math.tan(garage_tilt)) ** 0.5 garage_width = 2 * third_gable_area / garage_height # garage_length = garage_floor_area / garage_width - garage_depth_in_house = garage_height * math.tan(roof_tilt) # length from attached wall to roof connection point - attic_volume = (1 / 2 * square_area * attic_height + # prism over square - 1 / 2 * garage_protruded_area * garage_height + # prism over garage - 1 / 6 * garage_width * garage_depth_in_house * garage_height) # pyramid between prisms + garage_depth_in_house = garage_height * math.tan( + roof_tilt + ) # length from attached wall to roof connection point + attic_volume = ( + 1 / 2 * square_area * attic_height # prism over square + + 1 / 2 * garage_protruded_area * garage_height # prism over garage + + 1 / 6 * garage_width * garage_depth_in_house * garage_height + ) # pyramid between prisms else: attic_volume = 1 / 2 * attic_floor_area * attic_height # volume = 1/2 l*w*h - vented = attic.get('AtticType', {}).get('Attic', {}).get('Vented', True) - zones['Attic'] = { - 'Zone Area (m^2)': attic_floor_area, - 'Volume (m^3)': attic_volume, - 'Vented': vented, + vented = attic.get("AtticType", {}).get("Attic", {}).get("Vented", True) + zones["Attic"] = { + "Zone Area (m^2)": attic_floor_area, + "Volume (m^3)": attic_volume, + "Vented": vented, } # Add attic infiltration - inf_units = attic.get('VentilationRate', {}).get('UnitofMeasure') - if inf_units == 'ACHnatural': - attic_ach = attic['VentilationRate']['Value'] - zones['Attic'].update({ - 'Infiltration Method': 'ACH', - 'Air Changes (1/hour)': attic_ach, - }) - elif inf_units == 'SLA': + inf_units = attic.get("VentilationRate", {}).get("UnitofMeasure") + if inf_units == "ACHnatural": + attic_ach = attic["VentilationRate"]["Value"] + zones["Attic"].update( + { + "Infiltration Method": "ACH", + "Air Changes (1/hour)": attic_ach, + } + ) + elif inf_units == "SLA": # Update ELA based on attic area - attic_ela = attic['VentilationRate']['Value'] * attic_floor_area * 1e4 # m^2 to cm^2 - zones['Attic'].update({ - 'Infiltration Method': 'ELA', - 'ELA (cm^2)': attic_ela, - **utils_envelope.calculate_ela_coefficients('Attic', attic_height, building_height) - }) + attic_ela = ( + attic["VentilationRate"]["Value"] * attic_floor_area * 1e4 + ) # m^2 to cm^2 + zones["Attic"].update( + { + "Infiltration Method": "ELA", + "ELA (cm^2)": attic_ela, + **utils_envelope.calculate_ela_coefficients( + "Attic", attic_height, building_height + ), + } + ) elif not vented and inf_units is None: - zones['Attic'].update({ - 'Infiltration Method': 'ACH', - 'Air Changes (1/hour)': 0.1, - }) + zones["Attic"].update( + { + "Infiltration Method": "ACH", + "Air Changes (1/hour)": 0.1, + } + ) else: - raise OCHREException(f'Cannot parse Attic infiltration rate from properties: {attic}') + raise OCHREException( + f"Cannot parse Attic infiltration rate from properties: {attic}" + ) # Add foundation zone - if construction['Foundation Type'] is not None: - foundations = list(enclosure.get('Foundations', {}).values()) + if construction["Foundation Type"] is not None: + foundations = list(enclosure.get("Foundations", {}).values()) assert len(foundations) == 1 foundation = foundations[0] # Get foundation volume - foundation_height = boundaries['Foundation Wall']['Height (m)'] + foundation_height = boundaries["Foundation Wall"]["Height (m)"] foundation_volume = first_floor_area * foundation_height - zones['Foundation'] = { - 'Zone Type': construction['Foundation Type'], - 'Zone Area (m^2)': first_floor_area, - 'Volume (m^3)': foundation_volume, + zones["Foundation"] = { + "Zone Type": construction["Foundation Type"], + "Zone Area (m^2)": first_floor_area, + "Volume (m^3)": foundation_volume, } # Get foundation infiltration parameters (takes ACH or SLA) - if construction['Foundation Type'] == 'Crawlspace': - zones['Foundation']['Vented'] = foundation.get('FoundationType')['Crawlspace'].get('Vented', True) + if construction["Foundation Type"] == "Crawlspace": + zones["Foundation"]["Vented"] = foundation.get("FoundationType")[ + "Crawlspace" + ].get("Vented", True) else: - zones['Foundation']['Vented'] = False - - inf_units = foundation.get('VentilationRate', {}).get('UnitofMeasure') - inf_value = foundation.get('VentilationRate', {}).get('Value') - if inf_units == 'ACHnatural': - zones['Foundation'].update({ - 'Infiltration Method': 'ACH', - 'Air Changes (1/hour)': inf_value, - }) - elif inf_units == 'SLA': - fnd_ela = inf_value * convert(first_floor_area, 'm^2', 'cm^2') - zones['Foundation'].update({ - 'Infiltration Method': 'ELA', - 'ELA (cm^2)': fnd_ela, - **utils_envelope.calculate_ela_coefficients('Foundation', foundation_height) - }) - elif zones['Foundation']['Vented']: + zones["Foundation"]["Vented"] = False + + inf_units = foundation.get("VentilationRate", {}).get("UnitofMeasure") + inf_value = foundation.get("VentilationRate", {}).get("Value") + if inf_units == "ACHnatural": + zones["Foundation"].update( + { + "Infiltration Method": "ACH", + "Air Changes (1/hour)": inf_value, + } + ) + elif inf_units == "SLA": + fnd_ela = inf_value * convert(first_floor_area, "m^2", "cm^2") + zones["Foundation"].update( + { + "Infiltration Method": "ELA", + "ELA (cm^2)": fnd_ela, + **utils_envelope.calculate_ela_coefficients( + "Foundation", foundation_height + ), + } + ) + elif zones["Foundation"]["Vented"]: inf_value = 2 # taken from ResStock, options_lookup.tsv file - zones['Foundation'].update({ - 'Infiltration Method': 'ACH', - 'Air Changes (1/hour)': inf_value, - }) + zones["Foundation"].update( + { + "Infiltration Method": "ACH", + "Air Changes (1/hour)": inf_value, + } + ) else: - zones['Foundation'].update({ - 'Infiltration Method': None, - }) + zones["Foundation"].update( + { + "Infiltration Method": None, + } + ) # Add garage zone if has_garage: # Note: garage roof space is included in attic for 1-story homes # FUTURE: convert to ELA? Can use 1sqft at 4.9 SLA = 1.2854145 CFM50, will need to convert ACH50 to ACH - garage_volume = garage_floor_area * ceiling_height # excluding garage attic space - garage_roof_areas = boundaries.get('Garage Roof', {}).get('Area (m^2)', []) + garage_volume = ( + garage_floor_area * ceiling_height + ) # excluding garage attic space + garage_roof_areas = boundaries.get("Garage Roof", {}).get("Area (m^2)", []) if len(garage_roof_areas) > 0: # add garage roof space - should work for triangle roof or gable roof - garage_volume += 1/2 * math.atan(garage_tilt) * garage_protruded_area - - indoor_infiltration = list(enclosure['AirInfiltration'].values())[0] - indoor_ach = indoor_infiltration['BuildingAirLeakage']['AirLeakage'] # ACH50 - zones['Garage'] = { - 'Zone Area (m^2)': garage_floor_area, - 'Volume (m^3)': garage_volume, - 'Infiltration Method': 'ACH', - 'Air Changes (1/hour)': indoor_ach, + garage_volume += 1 / 2 * math.atan(garage_tilt) * garage_protruded_area + + indoor_infiltration = list(enclosure["AirInfiltration"].values())[0] + indoor_ach = indoor_infiltration["BuildingAirLeakage"]["AirLeakage"] # ACH50 + zones["Garage"] = { + "Zone Area (m^2)": garage_floor_area, + "Volume (m^3)": garage_volume, + "Infiltration Method": "ACH", + "Air Changes (1/hour)": indoor_ach, } return zones def add_interior_boundaries(hpxml, boundaries, zones): - enclosure_extn = hpxml['Enclosure'].get('extension', {}) + enclosure_extn = hpxml["Enclosure"].get("extension", {}) # Add interior wall boundary, assumes interior wall area is equal to floor area - int_walls = enclosure_extn.get('PartitionWallMass') + int_walls = enclosure_extn.get("PartitionWallMass") if int_walls: # For now, require the defaults - assert int_walls.get('AreaFraction', 1.0) == 1.0 - assert int_walls.get('InteriorFinish', {}).get('Type', 'gypsum board') == 'gypsum board' - assert int_walls.get('InteriorFinish', {}).get('Thickness', 0.5) == 0.5 - boundaries['Interior Wall'] = { - 'Area (m^2)': [zones['Indoor']['Zone Area (m^2)']], - 'Interior Zone': 'Indoor', - 'Exterior Zone': 'Indoor', - 'Insulation Level': 'Standard' + assert int_walls.get("AreaFraction", 1.0) == 1.0 + assert ( + int_walls.get("InteriorFinish", {}).get("Type", "gypsum board") + == "gypsum board" + ) + assert int_walls.get("InteriorFinish", {}).get("Thickness", 0.5) == 0.5 + boundaries["Interior Wall"] = { + "Area (m^2)": [zones["Indoor"]["Zone Area (m^2)"]], + "Interior Zone": "Indoor", + "Exterior Zone": "Indoor", + "Insulation Level": "Standard", } # Add zone furniture - automatically added if the zone exists - indoor_fraction = enclosure_extn.get('FurnitureMass', {}).get('AreaFraction') + indoor_fraction = enclosure_extn.get("FurnitureMass", {}).get("AreaFraction") for zone_name, zone in zones.items(): - if zone_name == 'Indoor' and indoor_fraction is not None: + if zone_name == "Indoor" and indoor_fraction is not None: fraction = indoor_fraction else: fraction = ZONE_FURNITURE_AREA_FRACTIONS[zone_name] - area = zone.get('Zone Area (m^2)', 0) + area = zone.get("Zone Area (m^2)", 0) if area * fraction > 0: - boundaries[f'{zone_name} Furniture'] = { - 'Area (m^2)': [area * fraction], - 'Interior Zone': zone_name, - 'Exterior Zone': zone_name, - 'Insulation Level': 'Standard', + boundaries[f"{zone_name} Furniture"] = { + "Area (m^2)": [area * fraction], + "Interior Zone": zone_name, + "Exterior Zone": zone_name, + "Insulation Level": "Standard", } return boundaries @@ -708,185 +885,461 @@ def parse_hpxml_envelope(hpxml, occupancy, **house_args): boundaries = add_interior_boundaries(hpxml, boundaries, zones) # Get adjusted number of bedrooms based on bedrooms and occupants - house_type = construction['House Type'] - n_occupants = occupancy['Number of Occupants (-)'] - if house_type in ['single-family detached', 'manufactured home']: + house_type = construction["House Type"] + n_occupants = occupancy["Number of Occupants (-)"] + if house_type in ["single-family detached", "manufactured home"]: n_bedrooms_adj = max(-1.47 + 1.69 * n_occupants, 0) - elif house_type in ['single-family attached', 'apartment unit']: + elif house_type in ["single-family attached", "apartment unit"]: n_bedrooms_adj = max(-0.68 + 1.09 * n_occupants, 0) else: - raise OCHREException(f'Unknown house type: {house_type}') - construction['Number of Bedrooms, Adjusted (-)'] = n_bedrooms_adj + raise OCHREException(f"Unknown house type: {house_type}") + construction["Number of Bedrooms, Adjusted (-)"] = n_bedrooms_adj return boundaries, zones, construction -def add_simple_schedule_params(extension, prefix=''): +def add_simple_schedule_params(extension, prefix=""): # Get HPXML weekday and weekend hourly schedule fractions and month multipliers # for details, see https://openstudio-hpxml.readthedocs.io/en/latest/workflow_inputs.html - if f'{prefix}WeekdayScheduleFractions' not in extension: + if f"{prefix}WeekdayScheduleFractions" not in extension: return {} else: return { - 'weekday_fractions': extension.get(f'{prefix}WeekdayScheduleFractions'), - 'weekend_fractions': extension.get(f'{prefix}WeekendScheduleFractions'), - 'month_multipliers': extension.get(f'{prefix}MonthlyScheduleMultipliers'), + "weekday_fractions": extension.get(f"{prefix}WeekdayScheduleFractions"), + "weekend_fractions": extension.get(f"{prefix}WeekendScheduleFractions"), + "month_multipliers": extension.get(f"{prefix}MonthlyScheduleMultipliers"), } def parse_hpxml_occupancy(hpxml): - occupants = hpxml['BuildingSummary']['BuildingOccupancy'] - extension = occupants.get('extension', {}) + occupants = hpxml["BuildingSummary"]["BuildingOccupancy"] + extension = occupants.get("extension", {}) return { - 'Number of Occupants (-)': occupants['NumberofResidents'], + "Number of Occupants (-)": occupants["NumberofResidents"], **add_simple_schedule_params(extension), } def parse_hvac(hvac_type, hvac_all): + def calc_eer2_from_seer2(seer2, number_of_speeds): + # Regressions based on Central ACs & HPs in ENERGY STAR product lists + if number_of_speeds == 1: + return min(0.73 * seer2 + 1.47, seer2) + elif number_of_speeds == 2: + return min(0.63 * seer2 + 2.34, seer2) + elif number_of_speeds == 4: + return min(0.31 * seer2 + 6.45, seer2) + else: + raise OCHREException(f"Unkown number of speeds: {number_of_speeds}.") + + def calc_seer2_from_seer(seer): + return seer * 0.95 # split and packaged system assumption from OS-HPXML + + def calc_eer2_from_eer(eer): + return eer * 0.95 # split and packaged system assumption from OS-HPXML + + def get_detailed_performance_data(cooling_or_heating_detailed_performance_data): + performance = {} + for n in range(len(cooling_or_heating_detailed_performance_data)): + if ( + cooling_or_heating_detailed_performance_data[n]["Efficiency"]["Units"] + != "COP" + ): + raise OCHREException( + "Detailed Performance Data efficiency units are not COP." + ) # not sure format of this + if ( + cooling_or_heating_detailed_performance_data[n]["OutdoorTemperature"] + ) not in performance.keys(): + performance[ + cooling_or_heating_detailed_performance_data[n][ + "OutdoorTemperature" + ] + ] = {} + + performance[ + round( + float( + cooling_or_heating_detailed_performance_data[n][ + "OutdoorTemperature" + ], + 1, + ) + ) + ][ + f"{cooling_or_heating_detailed_performance_data[n]['CapacityDescription']}_capacity" + ] = round( + cooling_or_heating_detailed_performance_data[n]["Capacity"], 2 + ) + performance[ + round( + float( + cooling_or_heating_detailed_performance_data[n][ + "OutdoorTemperature" + ], + 1, + ) + ) + ][ + f"{cooling_or_heating_detailed_performance_data[n]['CapacityDescription']}_COP" + ] = round( + { + cooling_or_heating_detailed_performance_data[n]["Efficiency"][ + "Value" + ] + }, + 2, + ) + return performance + + # Calculates COP82min from SEER2 using bi-linear interpolation per RESNET MINERS Addendum 82 + def set_default_cooling_detailed_performance( + number_of_speeds, seer2, eer2, c_d, cop, capacity + ): + def find_array_neighbor_values(sorted_array, target): + index = bisect.bisect_left(sorted_array, target) + if index == 0: # Target is less than or equal to the smallest element + idx1, idx2 = 0, 1 + elif index == len(sorted_array): # Target is greater than all elements + idx1, idx2 = -2, -1 + else: # Target is in the middle + idx1, idx2 = index - 1, index + return sorted_array[idx1], sorted_array[idx2] + + def interp4(x, y, x1, x2, y1, y2, fx1y1, fx1y2, fx2y1, fx2y2): + return ( + (fx1y1 / float((x2 - x1) * (y2 - y1))) * (x2 - x) * (y2 - y) + + (fx2y1 / float((x2 - x1) * (y2 - y1))) * (x - x1) * (y2 - y) + + (fx1y2 / float((x2 - x1) * (y2 - y1))) * (x2 - x) * (y - y1) + + (fx2y2 / float((x2 - x1) * (y2 - y1))) * (x - x1) * (y - y1) + ) + + def interp2(x, x0, x1, f0, f1): + return f0 + ((x - x0) / float(x1 - x0)) * (f1 - f0) + + def interpolate_seer2( + seer2, eer2, seer2_array, seer2_eer2_ratio_array, cop82min_array + ): + seer2_eer2_ratio = seer2 / eer2 + x1, x2 = find_array_neighbor_values(sorted(seer2_array), seer2) + y1, y2 = find_array_neighbor_values( + sorted(seer2_eer2_ratio_array), seer2_eer2_ratio + ) + x_indexes = [seer2_array.index(x) for x in [x1, x2]] + y_indexes = [seer2_eer2_ratio_array.index(x) for x in [y1, y2]] + fx1y1 = cop82min_array[x_indexes[0]][y_indexes[0]] + fx1y2 = cop82min_array[x_indexes[0]][y_indexes[1]] + fx2y1 = cop82min_array[x_indexes[1]][y_indexes[0]] + fx2y2 = cop82min_array[x_indexes[1]][y_indexes[1]] + return interp4( + seer2, seer2_eer2_ratio, x1, x2, y1, y2, fx1y1, fx1y2, fx2y1, fx2y2 + ) + + cooling_performance = {} + # Datapoints to be defaulted + capacity82min = None + cop82min = None + + capacity82full = None + cop82full = None + + capacity82max = None + cop82max = None + + capacity95min = None + cop95min = None + + capacity95full = capacity + cop95full = cop + + capacity95max = None + cop95max = None + + if number_of_speeds == 1: + qm95full = 0.936 # Q95full/Q82full + # 82F data point + capacity82full = capacity95full / qm95full + cop82full = convert( + seer2 / (1.0 - 0.5 * c_d), "Btu/hour", "W" + ) # assume efficiency is SEER, and apply 0.95 to conver to seer2 (split or packaged system) + elif number_of_speeds == 2: + qm95full = 0.936 # Q95full/Q82full + eirm95full = 1.244 # (P95full/Q95full)/(P82full/Q82full) + + # Regression table used to interpolate to get cop at 82F min speed + seer2_array = [6.0, 22.0] + seer2_eer2_ratio_array = [1.000, 2.400] + cop82min_array = [[1.777, 2.105], [6.517, 7.717]] + + cool_capacity_ratios = [0.728, 1.0] # Qmin/Qfull for all temperatures + + # 82F full speed + capacity82full = capacity95full / qm95full + cop82full = cop95full * eirm95full + # 82F min speed + capacity82min = capacity82full * cool_capacity_ratios[0] + cop82min = interpolate_seer2( + seer2, eer2, seer2_array, seer2_eer2_ratio_array, cop82min_array + ) + # 95F min speed + capacity95min = capacity95full * cool_capacity_ratios[0] + cop95min = cop82min / eirm95full + elif number_of_speeds == 4: + qr95full = 0.934 # Q95full/Q95max + qm95max = 0.940 # Q95max/Q82max + qm95min = 0.948 # Q95min/Q82min + eirr95full = 0.928 # (P95full/Q95full)/(P95max/Q95max) + eirm95max = 1.326 # (P95max/Q95max)/(P82max/Q82max) + eirm95min = 1.315 # (P95min/Q95min)/(P82min/Q82min) + + seer2_array = [14.0, 24.5, 35.0] + seer2_eer2_ratio_array = [1.000, 1.747, 2.120, 2.307, 2.400] + cop82min_array = [ + [4.047, 6.175, 14.240, 19.508, 23.029], + [7.061, 10.289, 23.262, 31.842, 37.513], + [10.058, 14.053, 30.962, 42.388, 49.863], + ] + cop82min = interpolate_seer2( + seer2, eer2, seer2_array, seer2_eer2_ratio_array, cop82min_array + ) + + cop95max = cop95full * eirr95full + cop95min = cop82min / eirm95min + cop82max = cop95max * eirm95max + cop82full = interp2(cop95full, cop95min, cop95max, cop82min, cop82max) + qr95min = ( + 1.0 / qr95full * (0.029 + 0.369 * cop82max / cop82min) + ) # Q95min/Q95max + + cool_capacity_ratios = [qr95min, 1.0, 1.0 / qr95full] + capacity95max = capacity95full * cool_capacity_ratios[-1] + capacity95min = capacity95full * cool_capacity_ratios[0] + capacity82max = capacity95max / qm95max + capacity82min = capacity95min / qm95min + if capacity82min is not None: + cooling_performance[82.0]["minimum_capacity"] = round(capacity82min, 2) + cooling_performance[82.0]["minimum_COP"] = round(cop82min, 2) + if capacity82full is not None: + cooling_performance[82.0]["nominal_capacity"] = round(capacity82full, 2) + cooling_performance[82.0]["nominal_COP"] = round(cop82full, 2) + if capacity82max is not None: + cooling_performance[82.0]["maximum_capacity"] = round(capacity82max, 2) + cooling_performance[82.0]["maximum_COP"] = round(cop82max, 2) + if capacity95min is not None: + cooling_performance[95.0]["minimum_capacity"] = round(capacity95min, 2) + cooling_performance[95.0]["minimum_COP"] = round(cop95min, 2) + if capacity82full is not None: + cooling_performance[95.0]["nominal_capacity"] = round(capacity95full, 2) + cooling_performance[95.0]["nominal_COP"] = round(cop95full, 2) + if capacity82max is not None: + cooling_performance[95.0]["maximum_capacity"] = round(capacity95max, 2) + cooling_performance[95.0]["maximum_COP"] = round(cop95max, 2) + return cooling_performance + # Get HVAC HPXML parameters from HVAC Plant or Heat Pump - system = hvac_all.get('HVACPlant', {}).get(f'{hvac_type}System') - heat_pump = hvac_all.get('HVACPlant', {}).get('HeatPump') + system = hvac_all.get("HVACPlant", {}).get(f"{hvac_type}System") + heat_pump = hvac_all.get("HVACPlant", {}).get("HeatPump") if system and heat_pump: - raise OCHREException(f'HVAC {hvac_type} system and heat pump cannot both be specified.') + raise OCHREException( + f"HVAC {hvac_type} system and heat pump cannot both be specified." + ) elif not system and not heat_pump: return None has_heat_pump = bool(heat_pump) hvac = heat_pump if has_heat_pump else system # Main HVAC parameters - name = hvac['HeatPumpType'] if has_heat_pump else hvac[f'{hvac_type}SystemType'] + name = hvac["HeatPumpType"] if has_heat_pump else hvac[f"{hvac_type}SystemType"] if isinstance(name, dict): # HVAC heating has different structure, ignroring pilot light for now assert len(name) == 1 name, data = list(name.items())[0] # pilot = data.get('PilotLight', False) # pilot_rate = data.get('extension', {}).get('PilotLightBtuh', 500) - fuel = hvac['HeatPumpFuel'] if has_heat_pump else hvac.get(f'{hvac_type}SystemFuel') - capacity = convert(hvac[f'{hvac_type}Capacity'], 'Btu/hour', 'W') - space_fraction = hvac.get(f'Fraction{hvac_type[:-3]}LoadServed', 1.0) - efficiency = hvac[f'Annual{hvac_type}Efficiency'] - if efficiency['Units'] in ['Percent', 'AFUE']: - cop = efficiency['Value'] - if efficiency['Units'] == 'Percent': - # for reporting only - efficiency['Value'] *= 100 - elif efficiency['Units'] in ['EER', 'SEER', 'HSPF']: - cop = convert(efficiency["Value"], "Btu/hour", "W") - else: - raise OCHREException(f'Unknown inputs for HVAC {hvac_type} efficiency: {efficiency}') - efficiency_string = f"{efficiency['Value']} {efficiency['Units']}" - + fuel = hvac["HeatPumpFuel"] if has_heat_pump else hvac.get(f"{hvac_type}SystemFuel") + capacity = convert(hvac[f"{hvac_type}Capacity"], "Btu/hour", "W") + space_fraction = hvac.get(f"Fraction{hvac_type[:-3]}LoadServed", 1.0) # Get number of speeds speed_options = { - 'single stage': 1, - 'two stage': 2, - 'variable speed': 4, + "single stage": 1, + "two stage": 2, + "variable speed": 4, } - if name == 'mini-split': - number_of_speeds = 4 # MSHP always variable speed - elif hvac.get('CompressorType') in speed_options: - number_of_speeds = speed_options[hvac.get('CompressorType')] - elif convert(cop, "W", "Btu/hour") <= 15: - number_of_speeds = 1 # Single-speed for SEER <= 15 - elif convert(cop, "W", "Btu/hour") <= 21: - number_of_speeds = 2 # Two-speed for 15 < SEER <= 21 - else: - number_of_speeds = 4 # Variable speed for SEER > 21 + if has_heat_pump or hvac_type == "Cooling": + if name in [ + "mini-split", + "air-to-air", + "central air conditioner", + "ground-to-air", + ]: + if hvac.get("CompressorType") in speed_options: + number_of_speeds = speed_options[hvac.get("CompressorType")] + else: # CompressorType is now a required input in OS-HPXML for specific system types + raise OCHREException(f"HVAC missing CompressorType input.") + else: + number_of_speeds = 1 + + cop = None + eer2 = None + seer2 = None + efficiency = hvac[f"Annual{hvac_type}Efficiency"] + for n in range(len(efficiency)): + if efficiency["Units"] in ["Percent", "AFUE"]: + cop = efficiency["Value"] + if efficiency["Units"] == "Percent": + # for reporting only + efficiency["Value"] *= 100 + elif efficiency["Units"] in ["EER"]: + eer2 = calc_eer2_from_eer(efficiency["Value"]) + cop = convert(eer2, "Btu/hour", "W") + elif efficiency["Units"] in ["EER2"]: + eer2 = efficiency["Value"] + cop = convert(eer2, "Btu/hour", "W") + elif efficiency["Units"] in ["SEER2"]: + seer2 = efficiency["Value"] + if eer2 is None: # default based on seer2 + eer2 = calc_eer2_from_seer2(seer2, number_of_speeds) + cop = convert(eer2, "Btu/hour", "W") + elif efficiency["Units"] in ["SEER"]: + if seer2 is None: + seer2 = calc_seer2_from_seer(efficiency["Value"]) + if eer2 is None: + eer2 = calc_eer2_from_seer2(seer2, number_of_speeds) + cop = convert(eer2, "Btu/hour", "W") + elif efficiency["Units"] in ["HSPF"]: + cop = convert(efficiency["Value"], "Btu/hour", "W") # TODO: Update this + else: + raise OCHREException( + f"Unknown inputs for HVAC {hvac_type} efficiency: {efficiency}" + ) + efficiency_string = f"{efficiency['Value']} {efficiency['Units']}" # Get SHR - is_heater = hvac_type == 'Heating' + is_heater = hvac_type == "Heating" if is_heater: shr = None elif has_heat_pump: - shr = hvac.get('CoolingSensibleHeatFraction') + shr = hvac.get("CoolingSensibleHeatFraction") else: - shr = hvac.get('SensibleHeatFraction') + shr = hvac.get("SensibleHeatFraction") # Get auxiliary power (fans, pumps, etc.) air flow rate - hvac_ext = hvac.get('extension', {}) - if name == 'Boiler': + hvac_ext = hvac.get("extension", {}) + if name == "Boiler": # Note: ResStock assumes 2080 hours/year, see hvac.rb line 1754 (get_default_boiler_eae) # see also ANSI/RESNET/ICC 301-2019 Equation 4.4-5 - aux_power = hvac.get('ElectricAuxiliaryEnergy', 0) / 2080 * 1000 # converts kWh/year to W - elif 'FanPowerWattsPerCFM' in hvac_ext: + aux_power = ( + hvac.get("ElectricAuxiliaryEnergy", 0) / 2080 * 1000 + ) # converts kWh/year to W + elif "FanPowerWattsPerCFM" in hvac_ext: # Note: air flow rate is only used for non-dymanic HVAC models with fans, e.g., furnaces # airflow_cfm = hvac_ext.get(f'{hvac_type}AirflowCFM', 0) cfm_per_ton = 350 if is_heater else 312 - power_per_cfm = hvac_ext.get('FanPowerWattsPerCFM', 0) - aux_power = power_per_cfm * cfm_per_ton * convert(capacity, 'W', 'refrigeration_ton') + power_per_cfm = hvac_ext.get("FanPowerWattsPerCFM", 0) + aux_power = ( + power_per_cfm * cfm_per_ton * convert(capacity, "W", "refrigeration_ton") + ) else: - aux_power = hvac_ext.get('FanPowerWatts', 0) + aux_power = hvac_ext.get("FanPowerWatts", 0) out = { - 'Equipment Name': name, - 'Fuel': fuel.capitalize(), - 'Capacity (W)': capacity, - 'EIR (-)': 1 / cop, - 'Rated Efficiency': efficiency_string, - 'SHR (-)': shr, - 'Conditioned Space Fraction (-)': space_fraction, - 'Number of Speeds (-)': number_of_speeds, - 'Rated Auxiliary Power (W)': aux_power, + "Equipment Name": name, + "Fuel": fuel.capitalize(), + "Capacity (W)": capacity, + "EIR (-)": 1 / cop, + "Rated Efficiency": efficiency_string, + "SHR (-)": shr, + "Conditioned Space Fraction (-)": space_fraction, + "Number of Speeds (-)": number_of_speeds, + "Rated Auxiliary Power (W)": aux_power, } # Add startup capacity degradation factor for AC and heat pumps if has_heat_pump or not is_heater: c_d = utils_equipment.calc_c_d(is_heater, name, cop, number_of_speeds) - out['Startup Capacity Degradation (-)'] = c_d + out["Startup Capacity Degradation (-)"] = c_d # Get HVAC setpoints, optional - controls = hvac_all['HVACControl'] - extension = controls.get('extension', {}) - if f'WeekdaySetpointTemps{hvac_type}Season' in extension: - weekday_setpoints = extension[f'WeekdaySetpointTemps{hvac_type}Season'] - weekend_setpoints = extension[f'WeekendSetpointTemps{hvac_type}Season'] - out.update({ - 'Weekday Setpoints (C)': convert(weekday_setpoints, 'degF', 'degC').tolist(), - 'Weekend Setpoints (C)': convert(weekend_setpoints, 'degF', 'degC').tolist(), - }) - elif f'SetpointTemp{hvac_type}Season' in controls: - weekday_setpoint = controls[f'SetpointTemp{hvac_type}Season'] - weekend_setpoint = controls[f'SetpointTemp{hvac_type}Season'] - out.update({ - 'Weekday Setpoints (C)': [convert(weekday_setpoint, 'degF', 'degC')] * 24, - 'Weekend Setpoints (C)': [convert(weekend_setpoint, 'degF', 'degC')] * 24, - }) - - - if has_heat_pump: + controls = hvac_all["HVACControl"] + extension = controls.get("extension", {}) + if f"WeekdaySetpointTemps{hvac_type}Season" in extension: + weekday_setpoints = extension[f"WeekdaySetpointTemps{hvac_type}Season"] + weekend_setpoints = extension[f"WeekendSetpointTemps{hvac_type}Season"] + out.update( + { + "Weekday Setpoints (C)": convert( + weekday_setpoints, "degF", "degC" + ).tolist(), + "Weekend Setpoints (C)": convert( + weekend_setpoints, "degF", "degC" + ).tolist(), + } + ) + elif f"SetpointTemp{hvac_type}Season" in controls: + weekday_setpoint = controls[f"SetpointTemp{hvac_type}Season"] + weekend_setpoint = controls[f"SetpointTemp{hvac_type}Season"] + out.update( + { + "Weekday Setpoints (C)": [convert(weekday_setpoint, "degF", "degC")] + * 24, + "Weekend Setpoints (C)": [convert(weekend_setpoint, "degF", "degC")] + * 24, + } + ) + + if has_heat_pump and hvac_type == "Cooling": # Other hvac types e.g. central AC? if heat_pump.get("CoolingDetailedPerformanceData") is not None: - cooling_detailed_performance_data = heat_pump.get("CoolingDetailedPerformanceData") - cooling_performance = {} - for n in range(len(cooling_detailed_performance_data)): - if cooling_detailed_performance_data[n]["Efficiency"]["Units"] != "COP": - print("non cop efficiency units") # FIXME: This should probably crash the sim. Unlikely we'll ever get something other than COP, but it's what we're expecting - raise OCHREException("Efficiency units not COP") #not sure format of this - if (cooling_detailed_performance_data[n]['OutdoorTemperature']) not in cooling_performance.keys(): - cooling_performance[cooling_detailed_performance_data[n]['OutdoorTemperature']] = {} - - cooling_performance[cooling_detailed_performance_data[n]['OutdoorTemperature']][f"{cooling_detailed_performance_data[n]['CapacityDescription']}_capacity"] = [cooling_detailed_performance_data[n]['Capacity']] - cooling_performance[cooling_detailed_performance_data[n]['OutdoorTemperature']][f"{cooling_detailed_performance_data[n]['CapacityDescription']}_COP"] = [f"{cooling_detailed_performance_data[n]['Efficiency']['Value']}"] - out['CoolingDetailedPerformance'] = cooling_performance - - #TODO: else? Right now we'd keep things as is, but we might want to change default to match changes Yueyue made in OS-HPXML + cooling_detailed_performance_data = heat_pump.get( + "CoolingDetailedPerformanceData" + ) + cooling_performance = get_detailed_performance_data( + cooling_detailed_performance_data + ) + if "nominal_COP" in cooling_performance[95.0].keys(): + cop = cooling_performance[95.0]["nominal_COP"] # override default COP + if "nominal_capacity" in cooling_performance[95.0].keys(): + if ( + abs(capacity - cooling_performance[95.0]["nominal_capacity"]) + > capacity * 0.01 + ): + raise OCHREException( + "Cooling nominal capacity inputs not consistent. Check CoolingCapacity and CoolingDetailedPerformanceData." + ) + out["CoolingDetailedPerformance"] = cooling_performance + else: + out["CoolingDetailedPerformance"] = ( + set_default_cooling_detailed_performance( + number_of_speeds, seer2, eer2, c_d, cop, capacity + ) + ) + + if has_heat_pump and hvac_type == "Heating": # Other hvac types e.g. central AC? + # TODO: else? Right now we'd keep things as is, but we might want to change default to match changes Yueyue made in OS-HPXML if heat_pump.get("HeatingDetailedPerformanceData") is not None: - heating_detailed_performance_data = heat_pump.get("HeatingDetailedPerformanceData") - heating_performance = {} - for n in range(len(cooling_detailed_performance_data)): - if heating_detailed_performance_data[n]["Efficiency"]["Units"] != "COP": - print("non cop efficiency units") # FIXME: This should probably crash the sim. Unlikely we'll ever get something other than COP, but it's what we're expecting - raise OCHREException("Efficiency units not COP") - if (heating_detailed_performance_data[n]['OutdoorTemperature']) not in heating_performance.keys(): - heating_performance[heating_detailed_performance_data[n]['OutdoorTemperature']] = {} - - heating_performance[heating_detailed_performance_data[n]['OutdoorTemperature']][f"{heating_detailed_performance_data[n]['CapacityDescription']}_capacity"] = [heating_detailed_performance_data[n]['Capacity']] - heating_performance[heating_detailed_performance_data[n]['OutdoorTemperature']][f"{heating_detailed_performance_data[n]['CapacityDescription']}_COP"] = [f"{heating_detailed_performance_data[n]['Efficiency']['Value']}"] - out['HeatingDetailedPerformance'] = heating_performance - - if has_heat_pump and hvac_type == 'Heating': - backup_fuel = heat_pump.get('BackupSystemFuel') - backup_capacity = heat_pump.get('BackupHeatingCapacity', 0) + heating_detailed_performance_data = heat_pump.get( + "HeatingDetailedPerformanceData" + ) + heating_performance = get_detailed_performance_data( + heating_detailed_performance_data + ) + if "nominal_COP" in heating_performance[47.0].keys(): + cop = heating_performance[47.0]["nominal_COP"] # override default COP + if "nominal_capacity" in heating_performance[47.0].keys(): + if ( + abs(capacity - heating_performance[47.0]["nominal_capacity"]) + > capacity * 0.01 + ): + raise OCHREException( + "Heating nominal capacity inputs not consistent. Check HeatingCapacity and HeatingDetailedPerformanceData." + ) + out["HeatingDetailedPerformance"] = heating_performance + else: + # TODO: Add default heating performance data + pass + + if has_heat_pump and hvac_type == "Heating": + backup_fuel = heat_pump.get("BackupSystemFuel") + backup_capacity = heat_pump.get("BackupHeatingCapacity", 0) backup_capacity = convert(backup_capacity, "Btu/hour", "W") # assumes efficiency units are in Percent or AFUE (0-1) backup_cop = heat_pump.get("BackupAnnualHeatingEfficiency", {}).get("Value") @@ -901,9 +1354,11 @@ def parse_hvac(hvac_type, hvac_all): ) er_lockout_temp = convert(er_lockout_temp, "degF", "degC") if backup_capacity: - if backup_fuel != 'electricity': - print(f'WARNING: Using electric resistance backup for ASHP instead of {backup_fuel} backup') - + if backup_fuel != "electricity": + print( + f"WARNING: Using electric resistance backup for ASHP instead of {backup_fuel} backup" + ) + out.update( { "Backup EIR (-)": 1 / backup_cop, @@ -914,70 +1369,87 @@ def parse_hvac(hvac_type, hvac_all): ) # Get duct info for calculating DSE - distribution = hvac_all.get('HVACDistribution', {}) - distribution_type = distribution.get('DistributionSystemType', {}) - air_distribution = distribution_type.get('AirDistribution', {}) - duct_leakage = air_distribution.get('DuctLeakageMeasurement') - ducts = [d for d in air_distribution.get('Ducts', {}).values() - if parse_zone_name(d.get('DuctLocation')) not in ['Indoor', None]] - - if f'Annual{hvac_type}DistributionSystemEfficiency' in distribution: + distribution = hvac_all.get("HVACDistribution", {}) + distribution_type = distribution.get("DistributionSystemType", {}) + air_distribution = distribution_type.get("AirDistribution", {}) + duct_leakage = air_distribution.get("DuctLeakageMeasurement") + ducts = [ + d + for d in air_distribution.get("Ducts", {}).values() + if parse_zone_name(d.get("DuctLocation")) not in ["Indoor", None] + ] + + if f"Annual{hvac_type}DistributionSystemEfficiency" in distribution: # Note, ducts are assumed to be in ambient space, DSE l=osses aren't added to another zone - out['Ducts'] = { - 'DSE (-)': distribution[f'Annual{hvac_type}DistributionSystemEfficiency'], - 'Zone': None, + out["Ducts"] = { + "DSE (-)": distribution[f"Annual{hvac_type}DistributionSystemEfficiency"], + "Zone": None, } elif duct_leakage is not None and len(ducts): # Get parameters to calculate DSE using ASHRAE 152 # Must be called within HVAC.__init__, as it requires multi-speed parameters assert len(ducts) == 2 assert len(duct_leakage) == 2 - duct_location = ducts[0]['DuctLocation'] + duct_location = ducts[0]["DuctLocation"] duct_zone = parse_zone_name(duct_location) duct_info = {} - for duct, duct_leakage, duct_type in zip(ducts, duct_leakage, ['supply', 'return']): - assert duct['DuctType'] == duct_type - assert duct_leakage['DuctType'] == duct_type - assert duct_leakage['DuctLeakage']['Units'] == 'Percent' or duct_leakage['DuctLeakage']['Value'] == 0 - duct_info.update({ - f'{duct_type.capitalize()} Leakage (-)': duct_leakage['DuctLeakage']['Value'], - f'{duct_type.capitalize()} Area (ft^2)': duct['DuctSurfaceArea'], # * duct['FractionDuctArea'], - f'{duct_type.capitalize()} R Value': duct['DuctInsulationRValue'], - }) - out['Ducts'] = { - 'Zone': duct_zone, + for duct, duct_leakage, duct_type in zip( + ducts, duct_leakage, ["supply", "return"] + ): + assert duct["DuctType"] == duct_type + assert duct_leakage["DuctType"] == duct_type + assert ( + duct_leakage["DuctLeakage"]["Units"] == "Percent" + or duct_leakage["DuctLeakage"]["Value"] == 0 + ) + duct_info.update( + { + f"{duct_type.capitalize()} Leakage (-)": duct_leakage[ + "DuctLeakage" + ]["Value"], + f"{duct_type.capitalize()} Area (ft^2)": duct[ + "DuctSurfaceArea" + ], # * duct['FractionDuctArea'], + f"{duct_type.capitalize()} R Value": duct["DuctInsulationRValue"], + } + ) + out["Ducts"] = { + "Zone": duct_zone, **duct_info, } return out -def calculate_odb_at_zero_cop_or_capacity(detailed_performance_data, user_odbs, property, find_high, min_cop_or_capacity=0): + +def calculate_odb_at_zero_cop_or_capacity( + detailed_performance_data, user_odbs, property, find_high, min_cop_or_capacity=0 +): # allow it to calculate min temp where cop is at a defined value (less than backup efficiency) (rn set to default of 1) - if find_high: + if find_high: if user_odbs[-1] in detailed_performance_data: - odb_1 = user_odbs[-1] # temperature - odb_dp1 = detailed_performance_data[user_odbs[-1]] # data + odb_1 = user_odbs[-1] # temperature + odb_dp1 = detailed_performance_data[user_odbs[-1]] # data else: - odb_dp1 = None # insufficient input ? + odb_dp1 = None # insufficient input ? if user_odbs[-2] in detailed_performance_data: odb_2 = user_odbs[-2] odb_dp2 = detailed_performance_data[user_odbs[-2]] else: - odb_dp2 = None # insufficient input ? + odb_dp2 = None # insufficient input ? else: if user_odbs[0] in detailed_performance_data: odb_1 = user_odbs[0] odb_dp1 = detailed_performance_data[user_odbs[0]] else: - odb_dp1 = None # insufficient input ? + odb_dp1 = None # insufficient input ? if user_odbs[1] in detailed_performance_data: odb_12 = user_odbs[1] odb_dp2 = detailed_performance_data[user_odbs[1]] else: - odb_dp2 = None # insufficient input ? - - slope = (odb_dp1[property] - odb_dp2[property]) / (odb_1 - odb_2) # syntax ? + odb_dp2 = None # insufficient input ? + + slope = (odb_dp1[property] - odb_dp2[property]) / (odb_1 - odb_2) # syntax ? # if (find_high & slope >= 0): # return 999999.0 @@ -985,42 +1457,55 @@ def calculate_odb_at_zero_cop_or_capacity(detailed_performance_data, user_odbs, # return -999999.0 # solve for intercept - intercept = odb_dp2[property] - slope*odb_2 - # find target odb - target_odb = (min_cop_or_capacity-intercept)/slope + intercept = odb_dp2[property] - slope * odb_2 + # find target odb + target_odb = (min_cop_or_capacity - intercept) / slope # add small buffer delta_odb = 1.0 if find_high: - return target_odb - delta_odb + return target_odb - delta_odb else: - return target_odb + delta_odb + return target_odb + delta_odb -def interpolate_to_odb_table_point(detailed_performance_data, property, target_odb): # removed capacity description as an input our property/capacity description are together (max cop / min capacity) + +def interpolate_to_odb_table_point( + detailed_performance_data, property, target_odb +): # removed capacity description as an input our property/capacity description are together (max cop / min capacity) if target_odb in detailed_performance_data.keys(): return detailed_performance_data[target_odb][property] - + user_odbs = sorted(set(list(detailed_performance_data.keys()))) if max(user_odbs) < target_odb: right_odb = user_odbs[-1] left_odb = user_odbs[-2] - elif (min(user_odbs) > target_odb): + elif min(user_odbs) > target_odb: right_odb = user_odbs[1] left_odb = user_odbs[0] else: right_odb = max(user_odbs) - left_odb = min(user_odbs) - - slope = (detailed_performance_data[right_odb][property] - detailed_performance_data[left_odb][property]) / (right_odb - left_odb) - val = (target_odb - left_odb) * slope + detailed_performance_data[left_odb][property] + left_odb = min(user_odbs) + + slope = ( + detailed_performance_data[right_odb][property] + - detailed_performance_data[left_odb][property] + ) / (right_odb - left_odb) + val = (target_odb - left_odb) * slope + detailed_performance_data[left_odb][ + property + ] return val -def interpolate_to_odb_table_points(detailed_performance_data, mode, compressor_lockout_temp, weather_temp): + +def interpolate_to_odb_table_points( + detailed_performance_data, mode, compressor_lockout_temp, weather_temp +): user_odbs = list(detailed_performance_data.keys()) # can do this or just code the variable names into the high/low odb at zero cop/capacity variables , TODO: update with gross cop/capacity - properties = list(detailed_performance_data[list(detailed_performance_data.keys())[0]].keys()) - for property in properties: + properties = list( + detailed_performance_data[list(detailed_performance_data.keys())[0]].keys() + ) + for property in properties: if "min" in properties[property] and "cop" in properties[property]: low_cop = property elif "max" in properties[property] and "cop" in properties[property]: @@ -1031,33 +1516,67 @@ def interpolate_to_odb_table_points(detailed_performance_data, mode, compressor_ high_capacity = property # calculate zero COP/capacity odb - if mode=="cooling": + if mode == "cooling": outdoor_dry_bulbs = [] # TODO: update with gross cop/capacity - high_odb_at_zero_cop = calculate_odb_at_zero_cop_or_capacity(detailed_performance_data, user_odbs, high_cop, True) - high_odb_at_zero_capacity = calculate_odb_at_zero_cop_or_capacity(detailed_performance_data, user_odbs, high_capacity, True) - low_odb_at_zero_cop = calculate_odb_at_zero_cop_or_capacity(detailed_performance_data, user_odbs, low_cop, False) - low_odb_at_zero_capacity = calculate_odb_at_zero_cop_or_capacity(detailed_performance_data, user_odbs, low_capacity, False) - - outdoor_dry_bulbs += [max(low_odb_at_zero_cop, low_odb_at_zero_capacity, 55)] # min cooling odb - outdoor_dry_bulbs += [min(high_odb_at_zero_cop, high_odb_at_zero_capacity, weather_temp)] # max cooling odb - else: + high_odb_at_zero_cop = calculate_odb_at_zero_cop_or_capacity( + detailed_performance_data, user_odbs, high_cop, True + ) + high_odb_at_zero_capacity = calculate_odb_at_zero_cop_or_capacity( + detailed_performance_data, user_odbs, high_capacity, True + ) + low_odb_at_zero_cop = calculate_odb_at_zero_cop_or_capacity( + detailed_performance_data, user_odbs, low_cop, False + ) + low_odb_at_zero_capacity = calculate_odb_at_zero_cop_or_capacity( + detailed_performance_data, user_odbs, low_capacity, False + ) + + outdoor_dry_bulbs += [ + max(low_odb_at_zero_cop, low_odb_at_zero_capacity, 55) + ] # min cooling odb + outdoor_dry_bulbs += [ + min(high_odb_at_zero_cop, high_odb_at_zero_capacity, weather_temp) + ] # max cooling odb + else: outdoor_dry_bulbs = [] # TODO: update with gross cop/capacity - high_odb_at_zero_cop = calculate_odb_at_zero_cop_or_capacity(detailed_performance_data, user_odbs, high_cop, False) - high_odb_at_zero_capacity = calculate_odb_at_zero_cop_or_capacity(detailed_performance_data, user_odbs, high_capacity, False) - low_odb_at_zero_cop = calculate_odb_at_zero_cop_or_capacity(detailed_performance_data, user_odbs, low_cop, True) - low_odb_at_zero_capacity = calculate_odb_at_zero_cop_or_capacity(detailed_performance_data, user_odbs, low_capacity, True) + high_odb_at_zero_cop = calculate_odb_at_zero_cop_or_capacity( + detailed_performance_data, user_odbs, high_cop, False + ) + high_odb_at_zero_capacity = calculate_odb_at_zero_cop_or_capacity( + detailed_performance_data, user_odbs, high_capacity, False + ) + low_odb_at_zero_cop = calculate_odb_at_zero_cop_or_capacity( + detailed_performance_data, user_odbs, low_cop, True + ) + low_odb_at_zero_capacity = calculate_odb_at_zero_cop_or_capacity( + detailed_performance_data, user_odbs, low_capacity, True + ) - outdoor_dry_bulbs += [max(low_odb_at_zero_cop, low_odb_at_zero_capacity, compressor_lockout_temp, weather_temp)] # min heating odb - outdoor_dry_bulbs += [min(high_odb_at_zero_cop, high_odb_at_zero_capacity, 60)] # max heating odb + outdoor_dry_bulbs += [ + max( + low_odb_at_zero_cop, + low_odb_at_zero_capacity, + compressor_lockout_temp, + weather_temp, + ) + ] # min heating odb + outdoor_dry_bulbs += [ + min(high_odb_at_zero_cop, high_odb_at_zero_capacity, 60) + ] # max heating odb for target_odb in outdoor_dry_bulbs: if target_odb not in user_odbs: - #make new sub dictionary for this in the detailed performance data dictionary + # make new sub dictionary for this in the detailed performance data dictionary detailed_performance_data[target_odb] = {} - for property in properties: # TODO: update with gross cop/capacity - detailed_performance_data[target_odb][property] = interpolate_to_odb_table_point(detailed_performance_data, property, target_odb) + for property in properties: # TODO: update with gross cop/capacity + detailed_performance_data[target_odb][property] = ( + interpolate_to_odb_table_point( + detailed_performance_data, property, target_odb + ) + ) + def parse_water_heater(water_heater, water, construction, solar_fraction=0): # Returns a dictionary of calculated water heater/water tank properties @@ -1072,20 +1591,24 @@ def parse_water_heater(water_heater, water, construction, solar_fraction=0): # https://www.ashrae.org/file%20library/conferences/specialty%20conferences/2020%20building%20performance/papers/d-bsc20-c039.pdf # Inputs from HPXML - water_heater_type = water_heater['WaterHeaterType'] - is_electric = water_heater['FuelType'] == 'electricity' - energy_factor = water_heater.get('EnergyFactor') - uniform_energy_factor = water_heater.get('UniformEnergyFactor') - n_beds = construction['Number of Bedrooms (-)'] - n_beds_adj = construction['Number of Bedrooms, Adjusted (-)'] + water_heater_type = water_heater["WaterHeaterType"] + is_electric = water_heater["FuelType"] == "electricity" + energy_factor = water_heater.get("EnergyFactor") + uniform_energy_factor = water_heater.get("UniformEnergyFactor") + n_beds = construction["Number of Bedrooms (-)"] + n_beds_adj = construction["Number of Bedrooms, Adjusted (-)"] # For tank water heaters only (HPWH does not include some of these) - volume_gal = water_heater.get('TankVolume') # in gallons - height = convert(water_heater.get('TankHeight', 4), 'ft', 'm') # assumed to be 4 ft - heating_capacity = water_heater.get('HeatingCapacity') - first_hour_rating = water_heater.get('FirstHourRating') - recovery_efficiency = water_heater.get('RecoveryEfficiency') - tank_jacket_r = water_heater.get('WaterHeaterInsulation', {}).get('Jacket', {}).get('JacketRValue', 0) + volume_gal = water_heater.get("TankVolume") # in gallons + height = convert(water_heater.get("TankHeight", 4), "ft", "m") # assumed to be 4 ft + heating_capacity = water_heater.get("HeatingCapacity") + first_hour_rating = water_heater.get("FirstHourRating") + recovery_efficiency = water_heater.get("RecoveryEfficiency") + tank_jacket_r = ( + water_heater.get("WaterHeaterInsulation", {}) + .get("Jacket", {}) + .get("JacketRValue", 0) + ) # calculate actual volume from rated volume if volume_gal is not None: @@ -1093,24 +1616,26 @@ def parse_water_heater(water_heater, water, construction, solar_fraction=0): volume_gal *= 0.9 else: volume_gal *= 0.95 - volume = convert(volume_gal, 'gallon', 'L') # in L + volume = convert(volume_gal, "gallon", "L") # in L else: volume = None if energy_factor is None and uniform_energy_factor is None: - raise OCHREException('Energy Factor or Uniform Energy Factor input required for Water Heater.') + raise OCHREException( + "Energy Factor or Uniform Energy Factor input required for Water Heater." + ) # calculate UA and eta_c for each water heater type - if water_heater_type == 'instantaneous water heater': + if water_heater_type == "instantaneous water heater": if energy_factor is None: energy_factor = uniform_energy_factor - performance_adjustment = water_heater.get('PerformanceAdjustment', 0.94) + performance_adjustment = water_heater.get("PerformanceAdjustment", 0.94) else: - performance_adjustment = water_heater.get('PerformanceAdjustment', 0.92) + performance_adjustment = water_heater.get("PerformanceAdjustment", 0.92) eta_c = energy_factor * performance_adjustment ua = 0.0 - elif water_heater_type == 'heat pump water heater': + elif water_heater_type == "heat pump water heater": assert is_electric eta_c = 1 # HPWH UA calculation taken from ResStock: @@ -1122,7 +1647,7 @@ def parse_water_heater(water_heater, water, construction, solar_fraction=0): else: ua = 4.7 - elif water_heater_type == 'storage water heater': + elif water_heater_type == "storage water heater": density = 8.2938 # lb/gal cp = 1.0007 # Btu/lb-F t_in = 58.0 # F @@ -1135,7 +1660,9 @@ def parse_water_heater(water_heater, water, construction, solar_fraction=0): t = 125.0 # F if first_hour_rating < 18.0: volume_drawn = 10.0 # gal - elif first_hour_rating < 51.0: # Includes 18 gal up to (but not including) 51 + elif ( + first_hour_rating < 51.0 + ): # Includes 18 gal up to (but not including) 51 volume_drawn = 38.0 # gal elif first_hour_rating < 75.0: volume_drawn = 55.0 # gal @@ -1148,107 +1675,151 @@ def parse_water_heater(water_heater, water, construction, solar_fraction=0): if not is_electric: if energy_factor is not None: ua = (recovery_efficiency / energy_factor - 1.0) / ( - (t - t_env) * (24.0 / q_load - 1.0 / (heating_capacity * energy_factor))) # Btu/hr-F - eta_c = (recovery_efficiency + ua * ( - t - t_env) / heating_capacity) # conversion efficiency is supposed to be calculated with initial tank ua + (t - t_env) + * (24.0 / q_load - 1.0 / (heating_capacity * energy_factor)) + ) # Btu/hr-F + eta_c = ( + recovery_efficiency + ua * (t - t_env) / heating_capacity + ) # conversion efficiency is supposed to be calculated with initial tank ua else: ua = ((recovery_efficiency / uniform_energy_factor) - 1.0) / ( - (t - t_env) * (24.0 / q_load) - ((t - t_env) / (heating_capacity * uniform_energy_factor))) # Btu/hr-F - eta_c = recovery_efficiency + ((ua * ( - t - t_env)) / heating_capacity) # conversion efficiency is slightly larger than recovery efficiency + (t - t_env) * (24.0 / q_load) + - ((t - t_env) / (heating_capacity * uniform_energy_factor)) + ) # Btu/hr-F + eta_c = recovery_efficiency + ( + (ua * (t - t_env)) / heating_capacity + ) # conversion efficiency is slightly larger than recovery efficiency energy_factor = 0.9066 * uniform_energy_factor + 0.0711 else: if energy_factor is not None: ua = q_load * (1.0 / energy_factor - 1.0) / ((t - t_env) * 24.0) else: - ua = q_load * (1.0 / uniform_energy_factor - 1.0) / ( - (24.0 * (t - t_env)) * (0.8 + 0.2 * ((t_in - t_env) / (t - t_env)))) + ua = ( + q_load + * (1.0 / uniform_energy_factor - 1.0) + / ( + (24.0 * (t - t_env)) + * (0.8 + 0.2 * ((t_in - t_env) / (t - t_env))) + ) + ) energy_factor = 2.4029 * uniform_energy_factor - 1.2844 eta_c = 1.0 else: - raise OCHREException(f'Unknown water heater type: {water_heater_type}') + raise OCHREException(f"Unknown water heater type: {water_heater_type}") # Increase insulation from tank jacket (reduces UA) if tank_jacket_r: jacket_insulation = 5.0 # R5, in F-ft2-hr/Btu jacket_thickness = 1 if is_electric and energy_factor < 0.7 else 2 # in inches - diameter = 2 * convert((volume / 1000 / height / math.pi) ** 0.5, 'm', 'ft') # in ft - a_side = math.pi * diameter * convert(height, 'm', 'ft') - u_pre_skin = 1.0 / (jacket_thickness * jacket_insulation + 1.0 / 1.3 + 1.0 / 52.8) + diameter = 2 * convert( + (volume / 1000 / height / math.pi) ** 0.5, "m", "ft" + ) # in ft + a_side = math.pi * diameter * convert(height, "m", "ft") + u_pre_skin = 1.0 / ( + jacket_thickness * jacket_insulation + 1.0 / 1.3 + 1.0 / 52.8 + ) ua -= tank_jacket_r / (1.0 / u_pre_skin + tank_jacket_r) * u_pre_skin * a_side - ua *= (1.0 - solar_fraction) + ua *= 1.0 - solar_fraction if ua < 0.0: - raise OCHREException('A negative water heater standby loss coefficient (UA) was calculated.' - ' Double check water heater inputs.') + raise OCHREException( + "A negative water heater standby loss coefficient (UA) was calculated." + " Double check water heater inputs." + ) if eta_c > 1.0: - raise OCHREException('A water heater heat source (either burner or element) efficiency of > 1 has been calculated.' - ' Double check water heater inputs.') + raise OCHREException( + "A water heater heat source (either burner or element) efficiency of > 1 has been calculated." + " Double check water heater inputs." + ) wh = { - 'Equipment Name': water_heater_type, - 'Fuel': water_heater['FuelType'].capitalize(), - 'Zone': parse_zone_name(water_heater['Location']), - 'Setpoint Temperature (C)': convert(water_heater.get('HotWaterTemperature', 125), 'degF', 'degC'), + "Equipment Name": water_heater_type, + "Fuel": water_heater["FuelType"].capitalize(), + "Zone": parse_zone_name(water_heater["Location"]), + "Setpoint Temperature (C)": convert( + water_heater.get("HotWaterTemperature", 125), "degF", "degC" + ), # 'Heat Transfer Coefficient (W/m^2/K)': u, - 'UA (W/K)': convert(ua, 'Btu/hour/degR', 'W/K'), - 'Efficiency (-)': eta_c, - 'Energy Factor (-)': energy_factor, - 'Tank Volume (L)': volume, - 'Tank Height (m)': height, + "UA (W/K)": convert(ua, "Btu/hour/degR", "W/K"), + "Efficiency (-)": eta_c, + "Energy Factor (-)": energy_factor, + "Tank Volume (L)": volume, + "Tank Height (m)": height, } if heating_capacity is not None: - wh['Capacity (W)'] = convert(heating_capacity, 'Btu/hour', 'W') + wh["Capacity (W)"] = convert(heating_capacity, "Btu/hour", "W") - if water_heater_type == 'heat pump water heater': + if water_heater_type == "heat pump water heater": # add HPWH COP, from ResStock, defaults to using UEF if uniform_energy_factor is None: uniform_energy_factor = (0.60522 + energy_factor) / 1.2101 - cop = 1.174536058 * uniform_energy_factor # Based on simulation of the UEF test procedure at varying COPs - wh['HPWH COP (-)'] = cop - if water_heater_type == 'instantaneous water heater' and wh['Fuel'] != 'Electricity': + cop = ( + 1.174536058 * uniform_energy_factor + ) # Based on simulation of the UEF test procedure at varying COPs + wh["HPWH COP (-)"] = cop + if ( + water_heater_type == "instantaneous water heater" + and wh["Fuel"] != "Electricity" + ): on_time_frac = [0.0269, 0.0333, 0.0397, 0.0462, 0.0529][n_beds - 1] - wh['Parasitic Power (W)'] = 5 + 60 * on_time_frac + wh["Parasitic Power (W)"] = 5 + 60 * on_time_frac # Add water draw parameters (clothes washer and dishwasher added later) # From ResStock, ANSI/RESNET 301-2014 Addendum A-2015, Amendment on Domestic Hot Water (DHW) Systems - assert water_heater['FractionDHWLoadServed'] == 1 - extension = water.get('extension', {}) + assert water_heater["FractionDHWLoadServed"] == 1 + extension = water.get("extension", {}) fixture_usage_ref = 14.6 + 10.0 * n_beds_adj # in gal/day - fixture_eff = 0.95 if list(water.get('WaterFixture', {}).values())[0].get('LowFlow', False) else 1.0 - fixture_multiplier = extension.get('WaterFixturesUsageMultiplier') + fixture_eff = ( + 0.95 + if list(water.get("WaterFixture", {}).values())[0].get("LowFlow", False) + else 1.0 + ) + fixture_multiplier = extension.get("WaterFixturesUsageMultiplier") fixture_gal_per_day = fixture_eff * fixture_usage_ref * fixture_multiplier # Add simple schedule for water fixtures - wh.update(add_simple_schedule_params(extension, prefix='WaterFixtures')) + wh.update(add_simple_schedule_params(extension, prefix="WaterFixtures")) # Get water distribution parameters # From ResStock, ANSI/RESNET 301-2014 Addendum A-2015, Amendment on Domestic Hot Water (DHW) Systems # 4.2.2.5.2.11 Service Hot Water Use - distribution = water.get('HotWaterDistribution', {}) - distribution_type = distribution.get('SystemType', {}) - distribution_r = distribution.get('PipeInsulation', {}).get('PipeRValue', 0) + distribution = water.get("HotWaterDistribution", {}) + distribution_type = distribution.get("SystemType", {}) + distribution_r = distribution.get("PipeInsulation", {}).get("PipeRValue", 0) if len(distribution_type) != 1: - raise OCHREException(f'Cannot handle multiple water distribution types: {distribution_type}') - elif 'Standard' in distribution_type: + raise OCHREException( + f"Cannot handle multiple water distribution types: {distribution_type}" + ) + elif "Standard" in distribution_type: distribution_factor = 0.9 if distribution_r >= 3 else 1.0 - total_sqft = convert(construction['Conditioned Floor Area (m^2)'], 'm^2', 'ft^2') - total_floors = construction['Total Floors'] - has_unfin_bsmt = construction['Foundation Type'] == 'Unfinished Basement' - default_length = 2.0 * (total_sqft / total_floors) ** 0.5 + 10.0 * total_floors + 5.0 * has_unfin_bsmt - p_ratio = distribution_type['Standard'].get('PipingLength', default_length) / default_length + total_sqft = convert( + construction["Conditioned Floor Area (m^2)"], "m^2", "ft^2" + ) + total_floors = construction["Total Floors"] + has_unfin_bsmt = construction["Foundation Type"] == "Unfinished Basement" + default_length = ( + 2.0 * (total_sqft / total_floors) ** 0.5 + + 10.0 * total_floors + + 5.0 * has_unfin_bsmt + ) + p_ratio = ( + distribution_type["Standard"].get("PipingLength", default_length) + / default_length + ) wd_eff = 1.0 - elif 'Recirculation' in distribution_type: + elif "Recirculation" in distribution_type: distribution_factor = 1.0 if distribution_r >= 3 else 1.11 - p_ratio = distribution_type['Recirculation'].get('BranchPipingLoopLength', 10) / 10 + p_ratio = ( + distribution_type["Recirculation"].get("BranchPipingLoopLength", 10) / 10 + ) wd_eff = 0.1 else: - print(f'Warning: Unknown water distribution type: {distribution_type}') + print(f"Warning: Unknown water distribution type: {distribution_type}") distribution_factor = 1.0 p_ratio = 1.0 wd_eff = 1.0 - ref_w_gpd = 9.8 * (n_beds_adj ** 0.43) + ref_w_gpd = 9.8 * (n_beds_adj**0.43) o_frac = 0.25 o_cd_eff = 0.0 o_w_gpd = ref_w_gpd * o_frac * (1.0 - o_cd_eff) @@ -1257,29 +1828,34 @@ def parse_water_heater(water_heater, water, construction, solar_fraction=0): distribution_gal_per_day = mw_gpd * fixture_multiplier # Combine fixture and distribution water draws in schedule - wh['Fixture Average Water Draw (L/day)'] = convert(fixture_gal_per_day + distribution_gal_per_day, 'gallon/day', - 'L/day') + wh["Fixture Average Water Draw (L/day)"] = convert( + fixture_gal_per_day + distribution_gal_per_day, "gallon/day", "L/day" + ) return wh def parse_clothes_washer(clothes_washer, n_bedrooms): # From ResStock, using ERI Version >= '2019A' - rated_annual_kwh = clothes_washer.get('RatedAnnualkWh', 400.0) - capacity = clothes_washer.get('Capacity', 3.0) # in ft^3 - usage = clothes_washer.get('LabelUsage', 6.0) # in cycles/week - gas_rate = clothes_washer.get('LabelGasRate', 1.09) # in $/therm - gas_cost = clothes_washer.get('LabelAnnualGasCost', 27.0) # in $/year - electric_rate = clothes_washer.get('LabelElectricRate', 0.12) # in $/kWh - multiplier = clothes_washer.get('extension', {}).get('UsageMultiplier', 1) + rated_annual_kwh = clothes_washer.get("RatedAnnualkWh", 400.0) + capacity = clothes_washer.get("Capacity", 3.0) # in ft^3 + usage = clothes_washer.get("LabelUsage", 6.0) # in cycles/week + gas_rate = clothes_washer.get("LabelGasRate", 1.09) # in $/therm + gas_cost = clothes_washer.get("LabelAnnualGasCost", 27.0) # in $/year + electric_rate = clothes_washer.get("LabelElectricRate", 0.12) # in $/kWh + multiplier = clothes_washer.get("extension", {}).get("UsageMultiplier", 1) gas_h20 = 0.3914 # (gal/cyc) per (therm/y) elec_h20 = 0.0178 # (gal/cyc) per (kWh/y) lcy = usage * 52.0 # label cycles per year scy = 164.0 + n_bedrooms * 46.5 - acy = scy * ((3.0 * 2.08 + 1.59) / (capacity * 2.08 + 1.59)) # Annual Cycles per Year - cw_appl = (gas_cost * gas_h20 / gas_rate - (rated_annual_kwh * electric_rate) * elec_h20 / electric_rate) / ( - electric_rate * gas_h20 / gas_rate - elec_h20) + acy = scy * ( + (3.0 * 2.08 + 1.59) / (capacity * 2.08 + 1.59) + ) # Annual Cycles per Year + cw_appl = ( + gas_cost * gas_h20 / gas_rate + - (rated_annual_kwh * electric_rate) * elec_h20 / electric_rate + ) / (electric_rate * gas_h20 / gas_rate - elec_h20) annual_kwh = cw_appl / lcy * acy annual_kwh *= multiplier @@ -1291,41 +1867,48 @@ def parse_clothes_washer(clothes_washer, n_bedrooms): frac_lat = 1.0 - frac_sens - frac_lost return { - 'Annual Electric Energy (kWh)': annual_kwh, - 'Average Water Draw (L/day)': convert(water_draw, 'gallon/day', 'L/day'), - 'Convective Gain Fraction (-)': frac_sens, - 'Radiative Gain Fraction (-)': 0, - 'Latent Gain Fraction (-)': frac_lat, - **add_simple_schedule_params(clothes_washer.get('extension', {})), + "Annual Electric Energy (kWh)": annual_kwh, + "Average Water Draw (L/day)": convert(water_draw, "gallon/day", "L/day"), + "Convective Gain Fraction (-)": frac_sens, + "Radiative Gain Fraction (-)": 0, + "Latent Gain Fraction (-)": frac_lat, + **add_simple_schedule_params(clothes_washer.get("extension", {})), } def parse_clothes_dryer(clothes_dryer, clothes_washer, n_bedrooms): # From ResStock, using ERI Version >= '2019A' - if 'CombinedEnergyFactor' not in clothes_dryer and 'EnergyFactor' in clothes_dryer: - combined_energy_factor = clothes_dryer['EnergyFactor'] / 1.15 + if "CombinedEnergyFactor" not in clothes_dryer and "EnergyFactor" in clothes_dryer: + combined_energy_factor = clothes_dryer["EnergyFactor"] / 1.15 else: - combined_energy_factor = clothes_dryer.get('CombinedEnergyFactor', 3.01) + combined_energy_factor = clothes_dryer.get("CombinedEnergyFactor", 3.01) # energy_factor = clothes_dryer.get('EnergyFactor', combined_energy_factor * 1.15) - fuel_type = clothes_dryer.get('FuelType', 'electricity') - if fuel_type in ['electricity', 'natural gas']: + fuel_type = clothes_dryer.get("FuelType", "electricity") + if fuel_type in ["electricity", "natural gas"]: pass - elif fuel_type in ['propane', 'fuel oil']: - print(f'WARNING: Converting clothes dryer fuel from {fuel_type} to natural gas.') + elif fuel_type in ["propane", "fuel oil"]: + print( + f"WARNING: Converting clothes dryer fuel from {fuel_type} to natural gas." + ) else: - raise OCHREException(f'Invalid fuel type for clothes dryer: {fuel_type}') - is_electric = fuel_type == 'electricity' - is_vented = clothes_dryer.get('Vented', True) - multiplier = clothes_dryer.get('extension', {}).get('UsageMultiplier', 1) - - washer_rated_annual_kwh = clothes_washer.get('RatedAnnualkWh', 400.0) - washer_imef = clothes_washer.get('IntegratedModifiedEnergyFactor', 1.0) # in ft^3 / (kWh/cyc) + raise OCHREException(f"Invalid fuel type for clothes dryer: {fuel_type}") + is_electric = fuel_type == "electricity" + is_vented = clothes_dryer.get("Vented", True) + multiplier = clothes_dryer.get("extension", {}).get("UsageMultiplier", 1) + + washer_rated_annual_kwh = clothes_washer.get("RatedAnnualkWh", 400.0) + washer_imef = clothes_washer.get( + "IntegratedModifiedEnergyFactor", 1.0 + ) # in ft^3 / (kWh/cyc) # modified_energy_factor = clothes_washer.get('ModifiedEnergyFactor', 0.503 + 0.95 * washer_imef) - washer_capacity = clothes_washer.get('Capacity', 3.0) # in ft^3 + washer_capacity = clothes_washer.get("Capacity", 3.0) # in ft^3 rmc = (0.97 * (washer_capacity / washer_imef) - washer_rated_annual_kwh / 312.0) / ( - (2.0104 * washer_capacity + 1.4242) * 0.455) + 0.04 - acy = (164.0 + 46.5 * n_bedrooms) * ((3.0 * 2.08 + 1.59) / (washer_capacity * 2.08 + 1.59)) + (2.0104 * washer_capacity + 1.4242) * 0.455 + ) + 0.04 + acy = (164.0 + 46.5 * n_bedrooms) * ( + (3.0 * 2.08 + 1.59) / (washer_capacity * 2.08 + 1.59) + ) annual_kwh = (((rmc - 0.04) * 100) / 55.5) * (8.45 / combined_energy_factor) * acy if is_electric: annual_therm = 0.0 @@ -1339,40 +1922,49 @@ def parse_clothes_dryer(clothes_dryer, clothes_washer, n_bedrooms): if is_electric: frac_sens = (1.0 - frac_lost) * 0.90 else: - elec_btu = convert(annual_kwh, 'kWh', 'Btu') + elec_btu = convert(annual_kwh, "kWh", "Btu") gas_btu = annual_therm * 1e5 - frac_sens = (1.0 - frac_lost) * ((0.90 * elec_btu + 0.8894 * gas_btu) / (elec_btu + gas_btu)) + frac_sens = (1.0 - frac_lost) * ( + (0.90 * elec_btu + 0.8894 * gas_btu) / (elec_btu + gas_btu) + ) frac_lat = 1.0 - frac_sens - frac_lost return { - 'Annual Electric Energy (kWh)': annual_kwh, - 'Annual Gas Energy (therms)': annual_therm, - 'Convective Gain Fraction (-)': frac_sens, - 'Radiative Gain Fraction (-)': 0, - 'Latent Gain Fraction (-)': frac_lat, - **add_simple_schedule_params(clothes_dryer.get('extension', {})), + "Annual Electric Energy (kWh)": annual_kwh, + "Annual Gas Energy (therms)": annual_therm, + "Convective Gain Fraction (-)": frac_sens, + "Radiative Gain Fraction (-)": 0, + "Latent Gain Fraction (-)": frac_lat, + **add_simple_schedule_params(clothes_dryer.get("extension", {})), } def parse_dishwasher(dishwasher, n_bedrooms): # From ResStock, using ERI Version >= '2019A' - rated_annual_kwh = dishwasher.get('RatedAnnualkWh', 467.0) + rated_annual_kwh = dishwasher.get("RatedAnnualkWh", 467.0) # energy_factor = dishwasher.get('EnergyFactor', 215.0 / rated_annual_kwh) - capacity = dishwasher.get('PlaceSettingCapacity', 12) - usage = dishwasher.get('LabelUsage', 4.0) # in cycles/week - gas_rate = dishwasher.get('LabelGasRate', 1.09) # in $/therm - gas_cost = dishwasher.get('LabelAnnualGasCost', 33.12) # in $/year - electric_rate = dishwasher.get('LabelElectricRate', 0.12) # in $/kWh - multiplier = dishwasher.get('extension', {}).get('UsageMultiplier', 1) + capacity = dishwasher.get("PlaceSettingCapacity", 12) + usage = dishwasher.get("LabelUsage", 4.0) # in cycles/week + gas_rate = dishwasher.get("LabelGasRate", 1.09) # in $/therm + gas_cost = dishwasher.get("LabelAnnualGasCost", 33.12) # in $/year + electric_rate = dishwasher.get("LabelElectricRate", 0.12) # in $/kWh + multiplier = dishwasher.get("extension", {}).get("UsageMultiplier", 1) usage = usage * 52.0 # in cycles/year? - kwh_per_cyc = ((gas_cost * 0.5497 / gas_rate - rated_annual_kwh * electric_rate * 0.02504 / electric_rate) / - (electric_rate * 0.5497 / gas_rate - 0.02504)) / usage + kwh_per_cyc = ( + ( + gas_cost * 0.5497 / gas_rate + - rated_annual_kwh * electric_rate * 0.02504 / electric_rate + ) + / (electric_rate * 0.5497 / gas_rate - 0.02504) + ) / usage dwcpy = (88.4 + 34.9 * n_bedrooms) * (12.0 / capacity) annual_kwh = kwh_per_cyc * dwcpy annual_kwh *= multiplier - water_draw = (rated_annual_kwh - kwh_per_cyc * usage) * 0.02504 * dwcpy / 365.0 # in gal/day + water_draw = ( + (rated_annual_kwh - kwh_per_cyc * usage) * 0.02504 * dwcpy / 365.0 + ) # in gal/day water_draw *= multiplier frac_lost = 0.40 @@ -1380,12 +1972,12 @@ def parse_dishwasher(dishwasher, n_bedrooms): frac_lat = 1.0 - frac_sens - frac_lost return { - 'Annual Electric Energy (kWh)': annual_kwh, - 'Average Water Draw (L/day)': convert(water_draw, 'gallon/day', 'L/day'), - 'Convective Gain Fraction (-)': frac_sens, - 'Radiative Gain Fraction (-)': 0, - 'Latent Gain Fraction (-)': frac_lat, - **add_simple_schedule_params(dishwasher.get('extension', {})), + "Annual Electric Energy (kWh)": annual_kwh, + "Average Water Draw (L/day)": convert(water_draw, "gallon/day", "L/day"), + "Convective Gain Fraction (-)": frac_sens, + "Radiative Gain Fraction (-)": 0, + "Latent Gain Fraction (-)": frac_lat, + **add_simple_schedule_params(dishwasher.get("extension", {})), } @@ -1394,20 +1986,22 @@ def parse_refrigerator(refrigerators, n_bedrooms): if not isinstance(refrigerators, list): refrigerators = [refrigerators] - extension_1 = refrigerators[0].get('extension', {}) + extension_1 = refrigerators[0].get("extension", {}) if len(refrigerators) >= 2: - print(f"Note: Combining {len(refrigerators)} refrigerators into 1 piece of equipment.") + print( + f"Note: Combining {len(refrigerators)} refrigerators into 1 piece of equipment." + ) assert all([r.get("extension", {}) == extension_1 for r in refrigerators]) annual_kwh = 0 annual_kwh_conditioned = 0 for r in refrigerators: is_primary = r.get("PrimaryIndicator", True) - extension = r.get('extension', {}) - multiplier = extension.get('UsageMultiplier', 1) - if 'AdjustedAnnualkWh' in extension: + extension = r.get("extension", {}) + multiplier = extension.get("UsageMultiplier", 1) + if "AdjustedAnnualkWh" in extension: r_energy = extension["AdjustedAnnualkWh"] * multiplier - elif 'RatedAnnualkWh' in r: + elif "RatedAnnualkWh" in r: r_energy = r["RatedAnnualkWh"] * multiplier elif is_primary: r_energy = (637.0 + 18.0 * n_bedrooms) * multiplier @@ -1417,7 +2011,7 @@ def parse_refrigerator(refrigerators, n_bedrooms): annual_kwh += r_energy default = "Indoor" if is_primary else None - if parse_zone_name(r.get('Location', default)) == "Indoor": + if parse_zone_name(r.get("Location", default)) == "Indoor": annual_kwh_conditioned += r_energy return { @@ -1431,9 +2025,9 @@ def parse_refrigerator(refrigerators, n_bedrooms): def parse_freezer(freezer, n_bedrooms): # Get freezer inputs from HPXML - extension = freezer.get('extension', {}) - multiplier = extension.get('UsageMultiplier', 1) - if 'RatedAnnualkWh' in freezer: + extension = freezer.get("extension", {}) + multiplier = extension.get("UsageMultiplier", 1) + if "RatedAnnualkWh" in freezer: annual_kwh = freezer["RatedAnnualkWh"] * multiplier else: annual_kwh = 319.8 * multiplier @@ -1454,18 +2048,20 @@ def parse_freezer(freezer, n_bedrooms): def parse_cooking_range(range_dict, oven_dict, n_bedrooms): # Get range inputs from HPXML # TODO: Check that booleans are passed as strings - fuel_type = range_dict.get('FuelType', 'electricity') - if fuel_type in ['electricity', 'natural gas']: + fuel_type = range_dict.get("FuelType", "electricity") + if fuel_type in ["electricity", "natural gas"]: pass - elif fuel_type in ['propane', 'fuel oil']: - print(f'WARNING: Converting cooking range fuel from {fuel_type} to natural gas.') + elif fuel_type in ["propane", "fuel oil"]: + print( + f"WARNING: Converting cooking range fuel from {fuel_type} to natural gas." + ) else: - raise OCHREException(f'Invalid fuel type for cooking range: {fuel_type}') - is_electric = fuel_type == 'electricity' - is_induction = range_dict.get('IsInduction', False) - is_convection = oven_dict.get('IsConvection', False) - multiplier = range_dict.get('extension', {}).get('UsageMultiplier', 1) - assert parse_zone_name(range_dict.get('Location')) in ['Indoor', None] + raise OCHREException(f"Invalid fuel type for cooking range: {fuel_type}") + is_electric = fuel_type == "electricity" + is_induction = range_dict.get("IsInduction", False) + is_convection = oven_dict.get("IsConvection", False) + multiplier = range_dict.get("extension", {}).get("UsageMultiplier", 1) + assert parse_zone_name(range_dict.get("Location")) in ["Indoor", None] # get total energy usage burner_ef = 0.91 if is_induction else 1 @@ -1484,21 +2080,25 @@ def parse_cooking_range(range_dict, oven_dict, n_bedrooms): if is_electric: frac_sens = (1.0 - frac_lost) * 0.90 else: - annual_gas_kwh = convert(annual_therm, 'therm', 'kWh') + annual_gas_kwh = convert(annual_therm, "therm", "kWh") annual_total_kwh = annual_kwh + annual_gas_kwh if annual_total_kwh != 0: - frac_sens = (1.0 - frac_lost) * (0.90 * annual_kwh + 0.7942 * annual_gas_kwh) / annual_total_kwh + frac_sens = ( + (1.0 - frac_lost) + * (0.90 * annual_kwh + 0.7942 * annual_gas_kwh) + / annual_total_kwh + ) else: frac_sens = 0 frac_lat = 1.0 - frac_sens - frac_lost return { - 'Annual Electric Energy (kWh)': annual_kwh, - 'Annual Gas Energy (therms)': annual_therm, - 'Convective Gain Fraction (-)': frac_sens, - 'Radiative Gain Fraction (-)': 0, - 'Latent Gain Fraction (-)': frac_lat, - **add_simple_schedule_params(range_dict.get('extension', {})) + "Annual Electric Energy (kWh)": annual_kwh, + "Annual Gas Energy (therms)": annual_therm, + "Convective Gain Fraction (-)": frac_sens, + "Radiative Gain Fraction (-)": 0, + "Latent Gain Fraction (-)": frac_lat, + **add_simple_schedule_params(range_dict.get("extension", {})), } @@ -1508,43 +2108,48 @@ def parse_lighting(location, df_lights, floor_area, extension=None): if extension is None: extension = {} - if 'LightingType' in df_lights: + if "LightingType" in df_lights: # Fractions of each lighting type specified - fractions = df_lights.set_index('LightingType')['FractionofUnitsInLocation'].to_dict() + fractions = df_lights.set_index("LightingType")[ + "FractionofUnitsInLocation" + ].to_dict() - f_led = fractions['LightEmittingDiode'] - f_flr = fractions['CompactFluorescent'] + fractions['FluorescentTube'] + f_led = fractions["LightEmittingDiode"] + f_flr = fractions["CompactFluorescent"] + fractions["FluorescentTube"] f_inc = 1 - f_led - f_flr - area_ft2 = convert(floor_area, 'm^2', 'ft^2') + area_ft2 = convert(floor_area, "m^2", "ft^2") e_led = 15 / 90 e_flr = 15 / 60 e_inc = 15 / 15 - if location == 'interior': + if location == "interior": int_adj = f_inc * e_inc + f_flr * e_flr + f_led * e_led - annual_kwh = (0.9 / 0.925 * (455.0 + 0.8 * area_ft2) * int_adj) + (0.1 * (455.0 + 0.8 * area_ft2)) - elif location == 'exterior': + annual_kwh = (0.9 / 0.925 * (455.0 + 0.8 * area_ft2) * int_adj) + ( + 0.1 * (455.0 + 0.8 * area_ft2) + ) + elif location == "exterior": ext_adj = f_inc * e_inc + f_flr * e_flr + f_led * e_led annual_kwh = (100.0 + 0.05 * area_ft2) * ext_adj - elif location == 'garage': + elif location == "garage": grg_adj = f_inc * e_inc + f_flr * e_flr + f_led * e_led annual_kwh = 100.0 * grg_adj else: - raise OCHREException(f'Unknown lighting location: {location}') - + raise OCHREException(f"Unknown lighting location: {location}") + else: # Annual kWh specified - load = df_lights['Load'] - assert load['Units'] == 'kWh/year' - annual_kwh = load['Value'] + load = df_lights["Load"] + assert load["Units"] == "kWh/year" + annual_kwh = load["Value"] # TODO: get default fractions/multipliers for lighting lights = { - 'Annual Electric Energy (kWh)': annual_kwh * extension.get(f'{location.capitalize()}UsageMultiplier'), - 'Convective Gain Fraction (-)': 1, - 'Radiative Gain Fraction (-)': 0, - 'Latent Gain Fraction (-)': 0, - **add_simple_schedule_params(extension, prefix=location.capitalize()) + "Annual Electric Energy (kWh)": annual_kwh + * extension.get(f"{location.capitalize()}UsageMultiplier"), + "Convective Gain Fraction (-)": 1, + "Radiative Gain Fraction (-)": 0, + "Latent Gain Fraction (-)": 0, + **add_simple_schedule_params(extension, prefix=location.capitalize()), } return lights @@ -1552,39 +2157,43 @@ def parse_lighting(location, df_lights, floor_area, extension=None): def parse_mel(mel, load_name, is_gas=None): if is_gas is None: - is_gas = mel['Load']['Units'] == 'therm/year' + is_gas = mel["Load"]["Units"] == "therm/year" - fuel_type = 'Gas' if is_gas else 'Electric' - load_units = 'therm' if is_gas else 'kWh' - if mel['Load']['Units'] != f'{load_units}/year': - raise OCHREException(f'Invalid load units for {load_name}:', mel['Load']['Units']) - if is_gas and mel.get('FuelType', 'natural gas') != 'natural gas': - raise OCHREException(f'Invalid fuel type for MGL:', mel['FuelLoadType']) + fuel_type = "Gas" if is_gas else "Electric" + load_units = "therm" if is_gas else "kWh" + if mel["Load"]["Units"] != f"{load_units}/year": + raise OCHREException( + f"Invalid load units for {load_name}:", mel["Load"]["Units"] + ) + if is_gas and mel.get("FuelType", "natural gas") != "natural gas": + raise OCHREException(f"Invalid fuel type for MGL:", mel["FuelLoadType"]) # TODO: use default annual load values from OS-HPXML - mel_load = mel['Load']['Value'] * mel.get('extension', {}).get('UsageMultiplier', 1) + mel_load = mel["Load"]["Value"] * mel.get("extension", {}).get("UsageMultiplier", 1) # TODO: use FracSensible from OS-HPXML - extension = mel.get('extension', {}) - if load_name == 'other': + extension = mel.get("extension", {}) + if load_name == "other": sensible_gain_default = 0.855 - elif load_name == 'fireplace': + elif load_name == "fireplace": sensible_gain_default = 0.5 else: sensible_gain_default = 0 - if load_name == 'other': + if load_name == "other": latent_gain_default = 0.045 - elif load_name == 'fireplace': + elif load_name == "fireplace": latent_gain_default = 0.1 else: latent_gain_default = 0 - load_units = 'therms' if is_gas else 'kWh' + load_units = "therms" if is_gas else "kWh" out = { - f'Annual {fuel_type} Energy ({load_units})': mel_load, - 'Convective Gain Fraction (-)': extension.get('FracSensible', sensible_gain_default), - 'Radiative Gain Fraction (-)': 0, - 'Latent Gain Fraction (-)': extension.get('FracLatent', latent_gain_default), + f"Annual {fuel_type} Energy ({load_units})": mel_load, + "Convective Gain Fraction (-)": extension.get( + "FracSensible", sensible_gain_default + ), + "Radiative Gain Fraction (-)": 0, + "Latent Gain Fraction (-)": extension.get("FracLatent", latent_gain_default), **add_simple_schedule_params(extension), } @@ -1594,7 +2203,7 @@ def parse_mel(mel, load_name, is_gas=None): def parse_mels(mel_dict, is_gas=False): mels = {} for mel in mel_dict.values(): - load_name = mel['FuelLoadType'] if is_gas else mel['PlugLoadType'] + load_name = mel["FuelLoadType"] if is_gas else mel["PlugLoadType"] ochre_name = MEL_NAMES[load_name] mels[ochre_name] = parse_mel(mel, load_name, is_gas=is_gas) @@ -1603,30 +2212,36 @@ def parse_mels(mel_dict, is_gas=False): def parse_ev(ev): # create EV equipment from MEL info - print('Creating EV equipment with a Level 2 charger from HPXML ') - ev_load = ev['Annual Electric Energy (kWh)'] + print("Creating EV equipment with a Level 2 charger from HPXML ") + ev_load = ev["Annual Electric Energy (kWh)"] return { - 'vehicle_type': 'BEV', - 'charging_level': 'Level 2', - 'range': 100 if ev_load < 1500 else 250 # Splits the two EV size options from ResStock + "vehicle_type": "BEV", + "charging_level": "Level 2", + "range": ( + 100 if ev_load < 1500 else 250 + ), # Splits the two EV size options from ResStock } def parse_pool_equipment(hpxml): # Get pool and spa equipment pool_equipment = {} - for hpxml_name in ['Pool', 'Spa']: - pool_list = list(hpxml.get(f'{hpxml_name}s', {}).values()) - if not pool_list or pool_list[0].get('Type', 'none') == 'none': + for hpxml_name in ["Pool", "Spa"]: + pool_list = list(hpxml.get(f"{hpxml_name}s", {}).values()) + if not pool_list or pool_list[0].get("Type", "none") == "none": continue - pump = list(pool_list[0]['Pumps'].values())[0] - heater = pool_list[0]['Heater'] - assert len(pool_list) == 1 and isinstance(pump, dict) and isinstance(heater, dict) - if 'Load' in pump and pump.get('Type', 'none') != 'none': - pool_equipment[f'{hpxml_name} Pump'] = parse_mel(pump, f'{hpxml_name} Pump') - if 'Load' in heater and heater.get('Type', 'none') != 'none': - pool_equipment[f'{hpxml_name} Heater'] = parse_mel(heater, f'{hpxml_name} Heater') + pump = list(pool_list[0]["Pumps"].values())[0] + heater = pool_list[0]["Heater"] + assert ( + len(pool_list) == 1 and isinstance(pump, dict) and isinstance(heater, dict) + ) + if "Load" in pump and pump.get("Type", "none") != "none": + pool_equipment[f"{hpxml_name} Pump"] = parse_mel(pump, f"{hpxml_name} Pump") + if "Load" in heater and heater.get("Type", "none") != "none": + pool_equipment[f"{hpxml_name} Heater"] = parse_mel( + heater, f"{hpxml_name} Heater" + ) return pool_equipment @@ -1635,25 +2250,25 @@ def parse_vent_fan(vent_fan): # Note: ventilation fan is not included in the schedule. Assumes a constant power schedule # TODO: Add local ventilation fan, garage fan # TODO: need to update indoor zone parameters too - whole_building_fan = vent_fan.get('UsedForWholeBuildingVentilation', False) - whole_house_fan = vent_fan.get('UsedForSeasonalCoolingLoadReduction', False) + whole_building_fan = vent_fan.get("UsedForWholeBuildingVentilation", False) + whole_house_fan = vent_fan.get("UsedForSeasonalCoolingLoadReduction", False) assert whole_building_fan or whole_house_fan - assert vent_fan.get('HoursInOperation', 24) == 24 + assert vent_fan.get("HoursInOperation", 24) == 24 - fan_type = 'whole house fan' if whole_house_fan else vent_fan['FanType'] - flow_rate = vent_fan['RatedFlowRate'] # in cfm + fan_type = "whole house fan" if whole_house_fan else vent_fan["FanType"] + flow_rate = vent_fan["RatedFlowRate"] # in cfm default_powers = { - 'energy recovery ventilator': 1, - 'heat recovery ventilator': 1, - 'balanced': 0.7, - 'exhaust only': 0.35, - 'supply only': 0.35, - 'whole house fan': 0.1 # in W/cfm + "energy recovery ventilator": 1, + "heat recovery ventilator": 1, + "balanced": 0.7, + "exhaust only": 0.35, + "supply only": 0.35, + "whole house fan": 0.1, # in W/cfm } - power = vent_fan.get('FanPower', flow_rate * default_powers[fan_type]) # in W + power = vent_fan.get("FanPower", flow_rate * default_powers[fan_type]) # in W return { - 'Power (W)': power, - 'Ventilation Rate (cfm)': flow_rate, + "Power (W)": power, + "Ventilation Rate (cfm)": flow_rate, # 'Fan Type': fan_type, # 'Convective Gain Fraction (-)': extension.get('FracSensible', sensible_gain_default), # 'Radiative Gain Fraction (-)': 0, @@ -1664,86 +2279,101 @@ def parse_vent_fan(vent_fan): def parse_hpxml_equipment(hpxml, occupancy, construction): # Add HVAC equipment equipment = {} - hvac_all = hpxml['Systems'].get('HVAC', {}) - for hvac_type in ['Heating', 'Cooling']: + hvac_all = hpxml["Systems"].get("HVAC", {}) + for hvac_type in ["Heating", "Cooling"]: hvac = parse_hvac(hvac_type, hvac_all) if hvac is not None: - equipment[f'HVAC {hvac_type}'] = hvac + equipment[f"HVAC {hvac_type}"] = hvac # Add water heater - water = hpxml['Systems'].get('WaterHeating', {}) - water_heater = water.get('WaterHeatingSystem') + water = hpxml["Systems"].get("WaterHeating", {}) + water_heater = water.get("WaterHeatingSystem") if water_heater is not None: # Add water heater parameters wh = parse_water_heater(water_heater, water, construction) - equipment['Water Heating'] = wh + equipment["Water Heating"] = wh # Add appliances - appliances = hpxml.get('Appliances', {}) - n_bedrooms = construction['Number of Bedrooms, Adjusted (-)'] + appliances = hpxml.get("Appliances", {}) + n_bedrooms = construction["Number of Bedrooms, Adjusted (-)"] # appliances = {re.sub(r"(\w)([A-Z])", r"\1 \2", name): val for name, val in appliances.items()} - if 'ClothesWasher' in appliances: - equipment['Clothes Washer'] = parse_clothes_washer(appliances['ClothesWasher'], - n_bedrooms) - if 'ClothesDryer' in appliances: - equipment['Clothes Dryer'] = parse_clothes_dryer(appliances['ClothesDryer'], - appliances['ClothesWasher'], - n_bedrooms) - if 'Dishwasher' in appliances: - equipment['Dishwasher'] = parse_dishwasher(appliances['Dishwasher'], n_bedrooms) - if 'Refrigerator' in appliances: - equipment['Refrigerator'] = parse_refrigerator(appliances['Refrigerator'], n_bedrooms) - if 'Freezer' in appliances: + if "ClothesWasher" in appliances: + equipment["Clothes Washer"] = parse_clothes_washer( + appliances["ClothesWasher"], n_bedrooms + ) + if "ClothesDryer" in appliances: + equipment["Clothes Dryer"] = parse_clothes_dryer( + appliances["ClothesDryer"], appliances["ClothesWasher"], n_bedrooms + ) + if "Dishwasher" in appliances: + equipment["Dishwasher"] = parse_dishwasher(appliances["Dishwasher"], n_bedrooms) + if "Refrigerator" in appliances: + equipment["Refrigerator"] = parse_refrigerator( + appliances["Refrigerator"], n_bedrooms + ) + if "Freezer" in appliances: equipment["Freezer"] = parse_freezer(appliances["Freezer"], n_bedrooms) # TODO: add dehumidifier - if 'CookingRange' in appliances: - equipment['Cooking Range'] = parse_cooking_range(appliances['CookingRange'], - appliances.get('Oven', {}), - n_bedrooms) + if "CookingRange" in appliances: + equipment["Cooking Range"] = parse_cooking_range( + appliances["CookingRange"], appliances.get("Oven", {}), n_bedrooms + ) # Add lighting - lighting = hpxml.get('Lighting', {}) - lighting_group = lighting.get('LightingGroup') + lighting = hpxml.get("Lighting", {}) + lighting_group = lighting.get("LightingGroup") if lighting_group is not None: df_lights_all = pd.DataFrame(lighting_group).T - extension = lighting.get('extension', {}) - for loc, df_lights in df_lights_all.groupby('Location'): - if loc == 'interior': - equipment['Indoor Lighting'] = parse_lighting(loc, df_lights, - construction['Indoor Floor Area (m^2)'], extension) - if construction['Foundation Type'] == 'Finished Basement': - equipment['Basement Lighting'] = parse_lighting(loc, df_lights, - construction['First Floor Area (m^2)'], extension) - elif loc == 'exterior': - equipment['Exterior Lighting'] = parse_lighting(loc, df_lights, - construction['Indoor Floor Area (m^2)'], extension) - elif loc == 'garage': - if construction['Garage Floor Area (m^2)'] > 0: - equipment['Garage Lighting'] = parse_lighting(loc, df_lights, - construction['Garage Floor Area (m^2)'], extension) + extension = lighting.get("extension", {}) + for loc, df_lights in df_lights_all.groupby("Location"): + if loc == "interior": + equipment["Indoor Lighting"] = parse_lighting( + loc, df_lights, construction["Indoor Floor Area (m^2)"], extension + ) + if construction["Foundation Type"] == "Finished Basement": + equipment["Basement Lighting"] = parse_lighting( + loc, + df_lights, + construction["First Floor Area (m^2)"], + extension, + ) + elif loc == "exterior": + equipment["Exterior Lighting"] = parse_lighting( + loc, df_lights, construction["Indoor Floor Area (m^2)"], extension + ) + elif loc == "garage": + if construction["Garage Floor Area (m^2)"] > 0: + equipment["Garage Lighting"] = parse_lighting( + loc, + df_lights, + construction["Garage Floor Area (m^2)"], + extension, + ) else: - print('WARNING: Skipping garage lighting, since no garage is modeled.') + print( + "WARNING: Skipping garage lighting, since no garage is modeled." + ) else: - raise OCHREException(f'Unknown lighting location: {loc}') + raise OCHREException(f"Unknown lighting location: {loc}") # Get plug loads and fuel loads, some strange behavior depending on number of MELs/MGLs - misc_loads = hpxml.get('MiscLoads', {}) - if 'PlugLoad' in misc_loads: - mel_dict = misc_loads.get('PlugLoad', {}) - if 'Load' in mel_dict: - mel_dict = {'PlugLoad1': mel_dict} - mgl_dict = misc_loads.get('FuelLoad', {}) - if 'Load' in mgl_dict: - mgl_dict = {'FuelLoad1': mgl_dict} + misc_loads = hpxml.get("MiscLoads", {}) + if "PlugLoad" in misc_loads: + mel_dict = misc_loads.get("PlugLoad", {}) + if "Load" in mel_dict: + mel_dict = {"PlugLoad1": mel_dict} + mgl_dict = misc_loads.get("FuelLoad", {}) + if "Load" in mgl_dict: + mgl_dict = {"FuelLoad1": mgl_dict} else: - mel_dict = {key: val for key, val in misc_loads.items() if 'PlugLoad' in key} - mgl_dict = {key: val for key, val in misc_loads.items() if 'FuelLoad' in key} + mel_dict = {key: val for key, val in misc_loads.items() if "PlugLoad" in key} + mgl_dict = {key: val for key, val in misc_loads.items() if "FuelLoad" in key} # Add MELs: TV, other MELs, well pump, EV mels = parse_mels(mel_dict) - if 'Electric Vehicle' in mels: - ev = mels.pop('Electric Vehicle') - equipment['Electric Vehicle'] = parse_ev(ev) + if "Electric Vehicle" in mels: + ev = mels.pop("Electric Vehicle") + equipment["Electric Vehicle"] = parse_ev(ev) equipment.update(mels) # Add MGLs: Grill, Fireplace, and Lighting @@ -1756,23 +2386,32 @@ def parse_hpxml_equipment(hpxml, occupancy, construction): equipment.update(pool_equipment) # Add ceiling fan - ceiling_fan = lighting.get('CeilingFan') + ceiling_fan = lighting.get("CeilingFan") if ceiling_fan: # From ResStock (ANSI 301-2019), flow rate = 3000 cfm, operation time = 10.5 hours per day - n_fans = ceiling_fan.get('Count', n_bedrooms + 1) - efficiency = ceiling_fan.get('Airflow', {}).get('Efficiency', 3000 / 42.6) # in cfm/W - fan_annual_kwh = n_fans * 3000 / efficiency * 10.5 * 365.0 / 1000 # in kWh/year (assumes 10.5 hr/day) - ceiling_fan['Load'] = {'Units': 'kWh/year', - 'Value': fan_annual_kwh} - equipment['Ceiling Fan'] = parse_mel(ceiling_fan, 'CeilingFan') + n_fans = ceiling_fan.get("Count", n_bedrooms + 1) + efficiency = ceiling_fan.get("Airflow", {}).get( + "Efficiency", 3000 / 42.6 + ) # in cfm/W + fan_annual_kwh = ( + n_fans * 3000 / efficiency * 10.5 * 365.0 / 1000 + ) # in kWh/year (assumes 10.5 hr/day) + ceiling_fan["Load"] = {"Units": "kWh/year", "Value": fan_annual_kwh} + equipment["Ceiling Fan"] = parse_mel(ceiling_fan, "CeilingFan") # Add Ventilation Fan - vent_fans = hpxml['Systems'].get('MechanicalVentilation', {}).get('VentilationFans', {}) - vent_fans = {key: val for key, val in vent_fans.items() - if val.get('UsedForWholeBuildingVentilation', False) or val.get('UsedForSeasonalCoolingLoadReduction', False)} + vent_fans = ( + hpxml["Systems"].get("MechanicalVentilation", {}).get("VentilationFans", {}) + ) + vent_fans = { + key: val + for key, val in vent_fans.items() + if val.get("UsedForWholeBuildingVentilation", False) + or val.get("UsedForSeasonalCoolingLoadReduction", False) + } if vent_fans: assert len(vent_fans) == 1 - equipment['Ventilation Fan'] = parse_vent_fan(list(vent_fans.values())[0]) + equipment["Ventilation Fan"] = parse_vent_fan(list(vent_fans.values())[0]) return equipment @@ -1786,38 +2425,44 @@ def load_hpxml(modify_hpxml_dict=None, **house_args): # Parse occupancy occupancy = parse_hpxml_occupancy(hpxml) - if 'Occupancy' in house_args: - occupancy = nested_update(occupancy, house_args.pop('Occupancy')) + if "Occupancy" in house_args: + occupancy = nested_update(occupancy, house_args.pop("Occupancy")) # Parse envelope properties and merge with house_args - boundaries, zones, construction = parse_hpxml_envelope(hpxml, occupancy, **house_args) - envelope = house_args.get('Envelope', {}) - if 'boundaries' in envelope: - boundaries = nested_update(boundaries, house_args['Envelope'].pop('boundaries')) - if 'zones' in envelope: - zones = nested_update(zones, house_args['Envelope'].pop('zones')) + boundaries, zones, construction = parse_hpxml_envelope( + hpxml, occupancy, **house_args + ) + envelope = house_args.get("Envelope", {}) + if "boundaries" in envelope: + boundaries = nested_update(boundaries, house_args["Envelope"].pop("boundaries")) + if "zones" in envelope: + zones = nested_update(zones, house_args["Envelope"].pop("zones")) # Parse equipment properties and merge with house_args equipment_dict = parse_hpxml_equipment(hpxml, occupancy, construction) - if 'Equipment' in house_args: - equipment_dict = nested_update(equipment_dict, house_args.pop('Equipment')) + if "Equipment" in house_args: + equipment_dict = nested_update(equipment_dict, house_args.pop("Equipment")) # update indoor zone infiltration (depends on equipment) # TODO: move to Envelope.init to get weather information (for air density) - zones['Indoor'].update(parse_indoor_infiltration(hpxml, construction, equipment_dict)) + zones["Indoor"].update( + parse_indoor_infiltration(hpxml, construction, equipment_dict) + ) # combine all HPXML properties properties = { - 'occupancy': occupancy, - 'construction': construction, - 'boundaries': boundaries, - 'zones': zones, - 'equipment': equipment_dict, + "occupancy": occupancy, + "construction": construction, + "boundaries": boundaries, + "zones": zones, + "equipment": equipment_dict, # 'location': location, } # Get weather station - weather_station = hpxml.get('ClimateandRiskZones', {}).get('WeatherStation', {}).get('Name') - weather_station = weather_station.strip('./') + weather_station = ( + hpxml.get("ClimateandRiskZones", {}).get("WeatherStation", {}).get("Name") + ) + weather_station = weather_station.strip("./") return properties, weather_station From efe0f917b8dc43de715c9eeb8e662267137ef989 Mon Sep 17 00:00:00 2001 From: Yueyue Zhou Date: Fri, 2 Jan 2026 17:55:57 -0700 Subject: [PATCH 04/14] fix some small issues --- ochre/utils/hpxml.py | 49 +++++++++++--------------------------------- 1 file changed, 12 insertions(+), 37 deletions(-) diff --git a/ochre/utils/hpxml.py b/ochre/utils/hpxml.py index 31590854..92df137e 100644 --- a/ochre/utils/hpxml.py +++ b/ochre/utils/hpxml.py @@ -938,57 +938,32 @@ def calc_seer2_from_seer(seer): def calc_eer2_from_eer(eer): return eer * 0.95 # split and packaged system assumption from OS-HPXML - def get_detailed_performance_data(cooling_or_heating_detailed_performance_data): + def get_detailed_performance_data(detailed_performance_data): performance = {} - for n in range(len(cooling_or_heating_detailed_performance_data)): - if ( - cooling_or_heating_detailed_performance_data[n]["Efficiency"]["Units"] - != "COP" - ): + for n in range(len(detailed_performance_data)): + if detailed_performance_data[n]["Efficiency"]["Units"] != "COP": raise OCHREException( "Detailed Performance Data efficiency units are not COP." ) # not sure format of this if ( - cooling_or_heating_detailed_performance_data[n]["OutdoorTemperature"] + detailed_performance_data[n]["OutdoorTemperature"] ) not in performance.keys(): performance[ - cooling_or_heating_detailed_performance_data[n][ - "OutdoorTemperature" - ] + round(float(detailed_performance_data[n]["OutdoorTemperature"]), 1) ] = {} performance[ - round( - float( - cooling_or_heating_detailed_performance_data[n][ - "OutdoorTemperature" - ], - 1, - ) - ) + round(float(detailed_performance_data[n]["OutdoorTemperature"]), 1) ][ - f"{cooling_or_heating_detailed_performance_data[n]['CapacityDescription']}_capacity" + f"{detailed_performance_data[n]['CapacityDescription']}_capacity" ] = round( - cooling_or_heating_detailed_performance_data[n]["Capacity"], 2 + float(detailed_performance_data[n]["Capacity"]), 2 ) + performance[ - round( - float( - cooling_or_heating_detailed_performance_data[n][ - "OutdoorTemperature" - ], - 1, - ) - ) - ][ - f"{cooling_or_heating_detailed_performance_data[n]['CapacityDescription']}_COP" - ] = round( - { - cooling_or_heating_detailed_performance_data[n]["Efficiency"][ - "Value" - ] - }, - 2, + round(float(detailed_performance_data[n]["OutdoorTemperature"]), 1) + ][f"{detailed_performance_data[n]['CapacityDescription']}_COP"] = round( + float(detailed_performance_data[n]["Efficiency"]["Value"]), 2 ) return performance From 63fe908b8f9b51e228ca376a30bd5bf7a4b6e53c Mon Sep 17 00:00:00 2001 From: Yueyue Zhou Date: Fri, 2 Jan 2026 18:22:22 -0700 Subject: [PATCH 05/14] store capacity watts instead of btu/hr in detailed performance data, more refactoring --- ochre/utils/hpxml.py | 39 ++++++++++++++++++++++----------------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/ochre/utils/hpxml.py b/ochre/utils/hpxml.py index 92df137e..c2c90845 100644 --- a/ochre/utils/hpxml.py +++ b/ochre/utils/hpxml.py @@ -945,26 +945,25 @@ def get_detailed_performance_data(detailed_performance_data): raise OCHREException( "Detailed Performance Data efficiency units are not COP." ) # not sure format of this - if ( - detailed_performance_data[n]["OutdoorTemperature"] - ) not in performance.keys(): - performance[ - round(float(detailed_performance_data[n]["OutdoorTemperature"]), 1) - ] = {} - - performance[ - round(float(detailed_performance_data[n]["OutdoorTemperature"]), 1) - ][ - f"{detailed_performance_data[n]['CapacityDescription']}_capacity" - ] = round( - float(detailed_performance_data[n]["Capacity"]), 2 + out_temp = round( + float(detailed_performance_data[n]["OutdoorTemperature"]), 1 ) + if out_temp not in performance.keys(): + performance[out_temp] = {} - performance[ - round(float(detailed_performance_data[n]["OutdoorTemperature"]), 1) - ][f"{detailed_performance_data[n]['CapacityDescription']}_COP"] = round( - float(detailed_performance_data[n]["Efficiency"]["Value"]), 2 + capacity_w = convert( + float(detailed_performance_data[n]["Capacity"]), "Btu/hour", "W" ) + cop = float(detailed_performance_data[n]["Efficiency"]["Value"]) + + performance[out_temp][ + f"{detailed_performance_data[n]['CapacityDescription']}_capacity" + ] = round(capacity_w, 2) + + performance[out_temp][ + f"{detailed_performance_data[n]['CapacityDescription']}_COP" + ] = round(cop, 2) + return performance # Calculates COP82min from SEER2 using bi-linear interpolation per RESNET MINERS Addendum 82 @@ -1091,6 +1090,8 @@ def interpolate_seer2( capacity95min = capacity95full * cool_capacity_ratios[0] capacity82max = capacity95max / qm95max capacity82min = capacity95min / qm95min + cooling_performance[82.0] = {} + cooling_performance[95.0] = {} if capacity82min is not None: cooling_performance[82.0]["minimum_capacity"] = round(capacity82min, 2) cooling_performance[82.0]["minimum_COP"] = round(cop82min, 2) @@ -1271,7 +1272,10 @@ def interpolate_seer2( cooling_detailed_performance_data ) if "nominal_COP" in cooling_performance[95.0].keys(): + print(1 / cop) cop = cooling_performance[95.0]["nominal_COP"] # override default COP + print(1 / cop) + out.update({"EIR (-)": 1 / cop}) if "nominal_capacity" in cooling_performance[95.0].keys(): if ( abs(capacity - cooling_performance[95.0]["nominal_capacity"]) @@ -1299,6 +1303,7 @@ def interpolate_seer2( ) if "nominal_COP" in heating_performance[47.0].keys(): cop = heating_performance[47.0]["nominal_COP"] # override default COP + out.update({"EIR (-)": 1 / cop}) if "nominal_capacity" in heating_performance[47.0].keys(): if ( abs(capacity - heating_performance[47.0]["nominal_capacity"]) From 6e96bd0a75b625853733e531f7dc3838ec534dbe Mon Sep 17 00:00:00 2001 From: Yueyue Zhou Date: Fri, 23 Jan 2026 16:34:55 -0700 Subject: [PATCH 06/14] heating default --- ochre/utils/equipment.py | 77 +++--- ochre/utils/hpxml.py | 542 +++++++++++++++++++++++++++++---------- 2 files changed, 448 insertions(+), 171 deletions(-) diff --git a/ochre/utils/equipment.py b/ochre/utils/equipment.py index 8fbc9e2d..719a3a1b 100644 --- a/ochre/utils/equipment.py +++ b/ochre/utils/equipment.py @@ -42,6 +42,12 @@ }, } +AIR_SOURCE_HEAT_RATED_ODB = round(convert(47.0, "degF", "degC"), 1) # Rated outdoor drybulb for air-source systems, heating +AIR_SOURCE_HEAT_RATED_IDB = round(convert(70.0, "degF", "degC"), 1) # Rated indoor drybulb for air-source systems, heating +AIR_SOURCE_COOL_RATED_ODB = round(convert(95.0, "degF", "degC"), 1) # Rated outdoor drybulb for air-source systems, cooling +AIR_SOURCE_COOL_RATED_OWB = round(convert(75.0, "degF", "degC"), 1) # Rated outdoor wetbulb for air-source systems, cooling +AIR_SOURCE_COOL_RATED_IDB = round(convert(80.0, "degF", "degC"), 1) # Rated indoor drybulb for air-source systems, cooling +AIR_SOURCE_COOL_RATED_IWB = round(convert(67.0, "degF", "degC"), 1) # Rated indoor wetbulb for air-source systems, cooling def get_duct_info(ducts, zones, boundaries, construction, location, **kwargs): # Get zone type from duct_zone and zone info @@ -429,39 +435,50 @@ def calculate_duct_dse(hvac, ducts, climate_file='ASHRAE152_climate_data.csv', return dse -def calc_c_d(is_heater, name, cop, number_of_speeds): - # Calculate coefficient of degredation (c_d) of equipment based on equipment type and EER/SEER/HSPF - # Should only affect cases with single speed and two speed compressor driven equipment (ASHP/AC) - # Capacity losses based on Jon Winkler's thesis and match what's in E+ with "Advanced Research Features for startup losses" - # https://drum.lib.umd.edu/bitstream/handle/1903/9493/Winkler_umd_0117E_10504.pdf?sequence=1&isAllowed=y page 200 - - if is_heater: - hspf = convert(cop, "W", "Btu/hour") - if number_of_speeds == 1: - if hspf < 7.0: - c_d = 0.2 - else: - c_d = 0.11 - elif number_of_speeds == 2: - c_d = 0.11 - else: - c_d = 0.0 # Do no capacity degradation at startup, since this isn't on/off equipment - else: #cooling equipment - seer = convert(cop, "W", "Btu/hour") - if name =='Room AC': - c_d = 0.22 - elif number_of_speeds == 1: - if seer < 13.0: - c_d = 0.2 - else: - c_d = 0.07 - elif number_of_speeds == 2: - c_d = 0.11 - else: - c_d = 0.0 # Do no capacity degradation at startup, since this isn't on/off equipment +def calc_c_d(name, number_of_speeds): + # Calculate coefficient of degredation (c_d) of equipment based on RESNET HERS Addendum 82 + if name == 'Room AC': + c_d = 0.22 + elif number_of_speeds in [1, 2]: + c_d = 0.08 + elif number_of_speeds == 4: + c_d = 0.4 + else: + c_d = 0.0 # Do no capacity degradation at startup, since this isn't on/off equipment return c_d +def calc_eer2_from_seer2(seer2, number_of_speeds): + # Regressions based on Central ACs & HPs in ENERGY STAR product lists + if number_of_speeds == 1: + return min(0.73 * seer2 + 1.47, seer2) + elif number_of_speeds == 2: + return min(0.63 * seer2 + 2.34, seer2) + elif number_of_speeds == 4: + return min(0.31 * seer2 + 6.45, seer2) + + +def calc_seer2_from_seer(seer, is_ducted): + if is_ducted: + return seer * 0.95 # ducted, split and packaged system assumption from OS-HPXML + else: + return seer + + +def calc_eer2_from_eer(eer, is_ducted): + if is_ducted: + return eer * 0.95 # ducted, split and packaged system assumption from OS-HPXML + else: + return eer + + +def calc_hspf2_from_hspf(hspf, is_ducted): + if is_ducted: + return hspf * 0.85 # ducted, split system assumption from OS-HPXML + else: + return hspf * 0.9 + + # Psychrometric functions for HVAC # Originally taken from BEopt python code, author: shorowit # see: https://cbr.nrel.gov/BEopt2/svn/trunk/Modeling/util.py diff --git a/ochre/utils/hpxml.py b/ochre/utils/hpxml.py index c2c90845..e5e9cda5 100644 --- a/ochre/utils/hpxml.py +++ b/ochre/utils/hpxml.py @@ -921,22 +921,26 @@ def parse_hpxml_occupancy(hpxml): def parse_hvac(hvac_type, hvac_all): - def calc_eer2_from_seer2(seer2, number_of_speeds): - # Regressions based on Central ACs & HPs in ENERGY STAR product lists - if number_of_speeds == 1: - return min(0.73 * seer2 + 1.47, seer2) - elif number_of_speeds == 2: - return min(0.63 * seer2 + 2.34, seer2) - elif number_of_speeds == 4: - return min(0.31 * seer2 + 6.45, seer2) - else: - raise OCHREException(f"Unkown number of speeds: {number_of_speeds}.") - - def calc_seer2_from_seer(seer): - return seer * 0.95 # split and packaged system assumption from OS-HPXML + def find_array_neighbor_values(sorted_array, target): + index = bisect.bisect_left(sorted_array, target) + if index == 0: # Target is less than or equal to the smallest element + idx1, idx2 = 0, 1 + elif index == len(sorted_array): # Target is greater than all elements + idx1, idx2 = -2, -1 + else: # Target is in the middle + idx1, idx2 = index - 1, index + return sorted_array[idx1], sorted_array[idx2] + + def interp4(x, y, x1, x2, y1, y2, fx1y1, fx1y2, fx2y1, fx2y2): + return ( + (fx1y1 / float((x2 - x1) * (y2 - y1))) * (x2 - x) * (y2 - y) + + (fx2y1 / float((x2 - x1) * (y2 - y1))) * (x - x1) * (y2 - y) + + (fx1y2 / float((x2 - x1) * (y2 - y1))) * (x2 - x) * (y - y1) + + (fx2y2 / float((x2 - x1) * (y2 - y1))) * (x - x1) * (y - y1) + ) - def calc_eer2_from_eer(eer): - return eer * 0.95 # split and packaged system assumption from OS-HPXML + def interp2(x, x0, x1, f0, f1): + return f0 + ((x - x0) / float(x1 - x0)) * (f1 - f0) def get_detailed_performance_data(detailed_performance_data): performance = {} @@ -945,8 +949,7 @@ def get_detailed_performance_data(detailed_performance_data): raise OCHREException( "Detailed Performance Data efficiency units are not COP." ) # not sure format of this - out_temp = round( - float(detailed_performance_data[n]["OutdoorTemperature"]), 1 + out_temp = round(convert(float(detailed_performance_data[n]["OutdoorTemperature"]), "degF", "degC"), 1 ) if out_temp not in performance.keys(): performance[out_temp] = {} @@ -966,31 +969,252 @@ def get_detailed_performance_data(detailed_performance_data): return performance - # Calculates COP82min from SEER2 using bi-linear interpolation per RESNET MINERS Addendum 82 - def set_default_cooling_detailed_performance( - number_of_speeds, seer2, eer2, c_d, cop, capacity + def set_default_heating_detailed_performance( + number_of_speeds, hspf2, qm17full, capacity, lct ): - def find_array_neighbor_values(sorted_array, target): - index = bisect.bisect_left(sorted_array, target) - if index == 0: # Target is less than or equal to the smallest element - idx1, idx2 = 0, 1 - elif index == len(sorted_array): # Target is greater than all elements - idx1, idx2 = -2, -1 - else: # Target is in the middle - idx1, idx2 = index - 1, index - return sorted_array[idx1], sorted_array[idx2] - - def interp4(x, y, x1, x2, y1, y2, fx1y1, fx1y2, fx2y1, fx2y2): - return ( - (fx1y1 / float((x2 - x1) * (y2 - y1))) * (x2 - x) * (y2 - y) - + (fx2y1 / float((x2 - x1) * (y2 - y1))) * (x - x1) * (y2 - y) - + (fx1y2 / float((x2 - x1) * (y2 - y1))) * (x2 - x) * (y - y1) - + (fx2y2 / float((x2 - x1) * (y2 - y1))) * (x - x1) * (y - y1) + # Calculates COP47full from HSPF2 using bi-linear interpolation per RESNET MINERS Addendum 82 + def interpolate_hspf2( + hspf2, qm17full, hspf2_array, qm17full_array, cop47full_array + ): + x1, x2 = find_array_neighbor_values(sorted(hspf2_array), hspf2) + y1, y2 = find_array_neighbor_values( + sorted(qm17full_array), qm17full ) + x_indexes = [hspf2_array.index(x) for x in [x1, x2]] + y_indexes = [qm17full_array.index(x) for x in [y1, y2]] + fx1y1 = cop47full_array[x_indexes[0]][y_indexes[0]] + fx1y2 = cop47full_array[x_indexes[0]][y_indexes[1]] + fx2y1 = cop47full_array[x_indexes[1]][y_indexes[0]] + fx2y2 = cop47full_array[x_indexes[1]][y_indexes[1]] + return interp4( + hspf2, qm17full, x1, x2, y1, y2, fx1y1, fx1y2, fx2y1, fx2y2 + ) + + heating_performance = {} + # Datapoints to be defaulted + capacity5min = None + cop5min = None - def interp2(x, x0, x1, f0, f1): - return f0 + ((x - x0) / float(x1 - x0)) * (f1 - f0) + capacity5full = None + cop5full = None + capacity5max = None + cop5max = None + + capacity17min = None + cop17min = None + + capacity17full = qm17full * capacity + cop17full = None + + capacity17max = None + cop17max = None + + capacity47min = None + cop47min = None + + capacity47full = capacity + cop47full = None + + capacity47max = None + cop47max = None + + capacityLCTmin = None + copLCTmin = None + + capacityLCTfull = None + copLCTfull = None + + capacityLCTmax = None + copLCTmax = None + if number_of_speeds == 1: + eirm17full = 1.356 # (P17full/Q17full)/(P47full/Q47full) + hspf2_array = [5.0, 6.5, 8.0, 9.5, 11.0] + qm17full_array = [0.5, 0.533, 0.6, 0.7333, 1.0] + cop47full_array = [[1.971, 1.963, 1.946, 1.915, 1.904], + [2.844, 2.801, 2.720, 2.589, 2.498], + [3.933, 3.819, 3.622, 3.318, 3.102], + [5.327, 5.085, 4.683, 4.111, 3.718], + [7.178, 6.699, 5.951, 4.975, 4.345]] + cop47full = interpolate_hspf2(hspf2, qm17full, hspf2_array, qm17full_array, cop47full_array) + # COPs @ 17F + cop17full = cop47full / eirm17full + # Capacities @ 5F + capacity5full = interp2(5.0, 17.0, 47.0, capacity17full, capacity47full) + # COPs @ 5F + if capacity5full > 0: + cop5full = capacity5full / interp2(5.0, 17.0, 47.0, capacity17full / cop17full, capacity47full / cop47full) + else: + cop5full = interp2(5.0, 17.0, 47.0, cop17full, cop47full) # Arbitrary + elif number_of_speeds == 2: + eirm17full = 1.356 # (P17full/Q17full)/(P47full/Q47full) + qrhmin = 0.712 # Qmin/Qfull + eirrhmin = 0.850 # (Pmin/Qmin)/(Pfull/Qfull) + + hspf2_array = [5.0, 6.5, 8.0, 9.5, 11.0] + qm17full_array = [0.5, 0.533, 0.6, 0.7333, 1.0] + cop47full_array = [[1.794, 1.779, 1.757, 1.720, 1.659], + [2.592, 2.540, 2.456, 2.325, 2.176], + [3.583, 3.464, 3.270, 2.980, 2.703], + [4.852, 4.611, 4.227, 3.691, 3.239], + [6.536, 6.073, 5.371, 4.467, 3.785]] + cop47full = interpolate_hspf2(hspf2, qm17full, hspf2_array, qm17full_array, cop47full_array) + + # Capacities @ 47F + capacity47min = capacity47full * qrhmin + + # COPs @ 47F + cop47min = cop47full / eirrhmin + + # Capacities @ 17F + capacity17min = capacity17full * qrhmin + + # COPs @ 17F + cop17full = cop47full / eirm17full + cop17min = cop17full / eirrhmin + + # Capacities @ 5F + if capacity47full > 0: + capacity5full = interp2(5.0, 17.0, 47.0, capacity17full, capacity47full) + capacity5min = interp2(5.0, 17.0, 47.0, capacity17min, capacity47min) + + # COPs @ 5F + if capacity5full > 0: + cop5full = capacity5full / interp2(5.0, 17.0, 47.0, capacity17full / cop17full, capacity47full / cop47full) + else: + cop5full = interp2(5.0, 17.0, 47.0, cop17full, cop47full) # Arbitrary + if capacity5min > 0: + cop5min = capacity5min / interp2(5.0, 17.0, 47.0, capacity17min / cop17min, capacity47min / cop47min) + else: + cop5min = interp2(5.0, 17.0, 47.0, cop17min, cop47min) # Arbitrary + elif number_of_speeds == 4: + qr47full = 0.908 # Q47full/Q47max + qr47min = 0.272 # Q47min/Q47max + qr17full = 0.817 # Q17full/Q17max + qr17min = 0.341 # Q17min/Q17max + qm5max = 0.866 # Q5max/Q17max + qr5full = 0.988 # Q5full/Q5max + qr5min = 0.321 # Q5min/Q5max + qmslopeLCTmax = -0.025 # (1.0 - Q5max/QLCTmax)/(5 - LCT) + qmslopeLCTmin = -0.024 # (1.0 - Q5min/QLCTmin)/(5 - LCT) + eirr47full = 0.939 # (P47full/Q47full)/(P47max/Q47max) + eirr47min = 0.730 # (P47min/Q47min)/(P47max/Q47max) + eirm17full = 1.351 # (P17full/Q17full)/(P47full/Q47full) + eirr17full = 0.902 # (P17full/Q17full)/(P17max/Q17max) + eirr17min = 0.798 # (P17min/Q17min)/(P17max/Q17max) + eirm5max = 1.164 # (P5max/Q5max)/(P17max/Q17max) + eirr5full = 1.000 # (P5full/Q5full)/(P5max/Q5max) + eirr5min = 0.866 # (P5min/Q5min)/(P5max/Q5max) + eirmslopeLCTmax = 0.012 # (1.0 - (PLCTmax/QLCTmax)/(P5max/Q5max))/(5 - LCT) + eirmslopeLCTmin = 0.012 # (1.0 - (PLCTmin/QLCTmin)/(P5min/Q5min))/(5 - LCT) + + hspf2_array = [7.0, 9.25, 11.5, 13.75, 16.0] + qm17full_array = [0.5, 0.54, 0.62, 0.78, 1.10] + cop47full_array = [[2.762, 2.696, 2.579, 2.467, 2.345], + [4.149, 3.941, 3.627, 3.305, 3.091], + [5.934, 5.490, 4.821, 4.167, 3.834], + [8.392, 7.463, 6.190, 5.054, 4.573], + [11.948, 10.060, 7.779, 5.967, 5.307]] + cop47full = interpolate_hspf2(hspf2, qm17full, hspf2_array, qm17full_array, cop47full_array) + + heat_capacity_ratios = [qr47min / qr47full, 1.0, 1.0 / qr47full] + # Capacities @ 47F + capacity47max = capacity47full * heat_capacity_ratios[-1] + capacity47min = capacity47full * heat_capacity_ratios[0] + + # COPs @ 47F + cop47max = cop47full * eirr47full + cop47min = cop47max / eirr47min + + # Capacities @ 17F + capacity17max = capacity17full / qr17full + capacity17min = capacity17full * qr17min / qr17full + + # COPs @ 17F + cop17full = cop47full / eirm17full + cop17max = cop17full * eirr17full + cop17min = cop17max / eirr17min + + # Capacities @ 5F + capacity5max = capacity17max * qm5max + capacity5full = capacity5max * qr5full + capacity5min = capacity5full * qr5min / qr5full + + # COPs @ 5F + cop5max = cop17max / eirm5max + cop5full = cop5max / eirr5full + cop5min = cop5max / eirr5min + + # lct already in degree C + lct_degF = convert(lct, "degC", "degF") + if lct_degF < 5.0: + # Capacities @ LCT + capacityLCTmax = capacity5max * (1.0 / (1.0 - qmslopeLCTmax * (5.0 - lct_degF))) + capacityLCTmin = capacity5min * (1.0 / (1.0 - qmslopeLCTmin * (5.0 - lct_degF))) + if capacityLCTmin > 0: + capacityLCTfull = interp2(capacity5full, capacity5min, capacity5max, capacityLCTmin, capacityLCTmax) + else: + capacityLCTfull = 0.0 + + # COPs @ LCT + copLCTmin = cop5min * (1.0 - eirmslopeLCTmin * (5.0 - lct_degF)) + copLCTmax = cop5max * (1.0 - eirmslopeLCTmax * (5.0 - lct_degF)) + if capacityLCTfull > 0: + copLCTfull = capacityLCTfull / interp2(capacity5full / cop5full, capacity5min / cop5min, capacity5max / cop5max, capacityLCTmin / copLCTmin, capacityLCTmax / copLCTmax) + else: + copLCTfull = interp2(lct_degF, 5.0, 17.0, cop5min, cop17min) # Arbitrary + + temp5 = round(convert(5.0, "degF", "degC"), 1) + heating_performance[temp5] = {} + temp17 = round(convert(17.0, "degF", "degC"), 1) + heating_performance[temp17] = {} + temp47 = utils_equipment.AIR_SOURCE_HEAT_RATED_ODB + heating_performance[temp47] = {} + if (capacityLCTmin is not None) or (capacityLCTfull is not None) or (capacityLCTmax is not None): + tempLCT = round(lct,1) + heating_performance[tempLCT] = {} + if capacityLCTmin is not None: + heating_performance[tempLCT]["minimum_capacity"] = round(capacityLCTmin, 2) + heating_performance[tempLCT]["minimum_COP"] = round(copLCTmin, 2) + if capacityLCTfull is not None: + heating_performance[tempLCT]["nominal_capacity"] = round(capacityLCTfull, 2) + heating_performance[tempLCT]["nominal_COP"] = round(copLCTfull, 2) + if capacityLCTmax is not None: + heating_performance[tempLCT]["maximum_capacity"] = round(capacityLCTmax, 2) + heating_performance[tempLCT]["maximum_COP"] = round(copLCTmax, 2) + if capacity5min is not None: + heating_performance[temp5]["minimum_capacity"] = round(capacity5min, 2) + heating_performance[temp5]["minimum_COP"] = round(cop5min, 2) + if capacity5full is not None: + heating_performance[temp5]["nominal_capacity"] = round(capacity5full, 2) + heating_performance[temp5]["nominal_COP"] = round(cop5full, 2) + if capacity5max is not None: + heating_performance[temp5]["maximum_capacity"] = round(capacity5max, 2) + heating_performance[temp5]["maximum_COP"] = round(cop5max, 2) + if capacity17min is not None: + heating_performance[temp17]["minimum_capacity"] = round(capacity17min, 2) + heating_performance[temp17]["minimum_COP"] = round(cop17min, 2) + if capacity17full is not None: + heating_performance[temp17]["nominal_capacity"] = round(capacity17full, 2) + heating_performance[temp17]["nominal_COP"] = round(cop17full, 2) + if capacity17max is not None: + heating_performance[temp17]["maximum_capacity"] = round(capacity17max, 2) + heating_performance[temp17]["maximum_COP"] = round(cop17max, 2) + if capacity47min is not None: + heating_performance[temp47]["minimum_capacity"] = round(capacity47min, 2) + heating_performance[temp47]["minimum_COP"] = round(cop47min, 2) + if capacity47full is not None: + heating_performance[temp47]["nominal_capacity"] = round(capacity47full, 2) + heating_performance[temp47]["nominal_COP"] = round(cop47full, 2) + if capacity47max is not None: + heating_performance[temp47]["maximum_capacity"] = round(capacity47max, 2) + heating_performance[temp47]["maximum_COP"] = round(cop47max, 2) + return heating_performance + + def set_default_cooling_detailed_performance( + number_of_speeds, seer2, eer2, c_d, capacity + ): + # Calculates COP82min from SEER2 using bi-linear interpolation per RESNET MINERS Addendum 82 def interpolate_seer2( seer2, eer2, seer2_array, seer2_eer2_ratio_array, cop82min_array ): @@ -1024,7 +1248,7 @@ def interpolate_seer2( cop95min = None capacity95full = capacity - cop95full = cop + cop95full = convert(eer2, "Btu/hour", "W") capacity95max = None cop95max = None @@ -1090,26 +1314,28 @@ def interpolate_seer2( capacity95min = capacity95full * cool_capacity_ratios[0] capacity82max = capacity95max / qm95max capacity82min = capacity95min / qm95min - cooling_performance[82.0] = {} - cooling_performance[95.0] = {} + temp82 = round(convert(82.0, "degF", "degC"), 1) + cooling_performance[temp82] = {} + temp95 = utils_equipment.AIR_SOURCE_COOL_RATED_ODB + cooling_performance[temp95] = {} if capacity82min is not None: - cooling_performance[82.0]["minimum_capacity"] = round(capacity82min, 2) - cooling_performance[82.0]["minimum_COP"] = round(cop82min, 2) + cooling_performance[temp82]["minimum_capacity"] = round(capacity82min, 2) + cooling_performance[temp82]["minimum_COP"] = round(cop82min, 2) if capacity82full is not None: - cooling_performance[82.0]["nominal_capacity"] = round(capacity82full, 2) - cooling_performance[82.0]["nominal_COP"] = round(cop82full, 2) + cooling_performance[temp82]["nominal_capacity"] = round(capacity82full, 2) + cooling_performance[temp82]["nominal_COP"] = round(cop82full, 2) if capacity82max is not None: - cooling_performance[82.0]["maximum_capacity"] = round(capacity82max, 2) - cooling_performance[82.0]["maximum_COP"] = round(cop82max, 2) + cooling_performance[temp82]["maximum_capacity"] = round(capacity82max, 2) + cooling_performance[temp82]["maximum_COP"] = round(cop82max, 2) if capacity95min is not None: - cooling_performance[95.0]["minimum_capacity"] = round(capacity95min, 2) - cooling_performance[95.0]["minimum_COP"] = round(cop95min, 2) + cooling_performance[temp95]["minimum_capacity"] = round(capacity95min, 2) + cooling_performance[temp95]["minimum_COP"] = round(cop95min, 2) if capacity82full is not None: - cooling_performance[95.0]["nominal_capacity"] = round(capacity95full, 2) - cooling_performance[95.0]["nominal_COP"] = round(cop95full, 2) + cooling_performance[temp95]["nominal_capacity"] = round(capacity95full, 2) + cooling_performance[temp95]["nominal_COP"] = round(cop95full, 2) if capacity82max is not None: - cooling_performance[95.0]["maximum_capacity"] = round(capacity95max, 2) - cooling_performance[95.0]["maximum_COP"] = round(cop95max, 2) + cooling_performance[temp95]["maximum_capacity"] = round(capacity95max, 2) + cooling_performance[temp95]["maximum_COP"] = round(cop95max, 2) return cooling_performance # Get HVAC HPXML parameters from HVAC Plant or Heat Pump @@ -1142,53 +1368,59 @@ def interpolate_seer2( "variable speed": 4, } if has_heat_pump or hvac_type == "Cooling": + # TODO: ERROR checking for unsupported system types. if name in [ "mini-split", "air-to-air", - "central air conditioner", - "ground-to-air", + "central air conditioner" ]: if hvac.get("CompressorType") in speed_options: number_of_speeds = speed_options[hvac.get("CompressorType")] else: # CompressorType is now a required input in OS-HPXML for specific system types - raise OCHREException(f"HVAC missing CompressorType input.") + raise OCHREException("HVAC missing CompressorType input.") else: number_of_speeds = 1 - cop = None - eer2 = None - seer2 = None + # Efficiency input and conversion + efficiency_map = {} efficiency = hvac[f"Annual{hvac_type}Efficiency"] + valid_efficiency_units = {"Cooling": ["SEER", "SEER2", "EER", "EER2"], + "Heating": ["Percent", "AFUE", "HSPF", "HSPF2"]} for n in range(len(efficiency)): - if efficiency["Units"] in ["Percent", "AFUE"]: - cop = efficiency["Value"] - if efficiency["Units"] == "Percent": - # for reporting only - efficiency["Value"] *= 100 - elif efficiency["Units"] in ["EER"]: - eer2 = calc_eer2_from_eer(efficiency["Value"]) - cop = convert(eer2, "Btu/hour", "W") - elif efficiency["Units"] in ["EER2"]: - eer2 = efficiency["Value"] - cop = convert(eer2, "Btu/hour", "W") - elif efficiency["Units"] in ["SEER2"]: - seer2 = efficiency["Value"] - if eer2 is None: # default based on seer2 - eer2 = calc_eer2_from_seer2(seer2, number_of_speeds) - cop = convert(eer2, "Btu/hour", "W") - elif efficiency["Units"] in ["SEER"]: - if seer2 is None: - seer2 = calc_seer2_from_seer(efficiency["Value"]) - if eer2 is None: - eer2 = calc_eer2_from_seer2(seer2, number_of_speeds) - cop = convert(eer2, "Btu/hour", "W") - elif efficiency["Units"] in ["HSPF"]: - cop = convert(efficiency["Value"], "Btu/hour", "W") # TODO: Update this + # Store all the efficiencies in the map + # Separate logic for cooling and heating units + if efficiency["Units"] in valid_efficiency_units[hvac_type]: + efficiency_map[efficiency["Units"]] = efficiency["Value"] else: raise OCHREException( f"Unknown inputs for HVAC {hvac_type} efficiency: {efficiency}" ) - efficiency_string = f"{efficiency['Value']} {efficiency['Units']}" + + is_ducted = bool(hvac_all.get("HVACDistribution", {}).get("DistributionSystemType", {}).get("AirDistribution")) + detailed_performance_sys_types = ["air-to-air", "mini-split", "central air conditioner"] + if name in detailed_performance_sys_types: + # Default efficiencies that are missing from inputs + if hvac_type == "Cooling": + if "SEER2" not in efficiency_map.keys() and "SEER" in efficiency_map.keys(): + efficiency_map["SEER2"] = utils_equipment.calc_seer2_from_seer(efficiency_map["SEER"], is_ducted) + if "EER2" not in efficiency_map.keys(): + if "EER" in efficiency_map.keys(): + efficiency_map["EER2"] = utils_equipment.calc_eer2_from_eer(efficiency_map["EER"], is_ducted) + elif "SEER2" in efficiency_map.keys(): + # Default EER2 based on SEER2 + efficiency_map["EER2"] = utils_equipment.calc_eer2_from_seer2(efficiency_map["SEER2"], number_of_speeds) + else: + raise OCHREException( + f"Missing inputs for HVAC {hvac_type} efficiency, need SEER/SEER2, or EER2/EER2 to be provided." + ) + elif hvac_type == "Heating": + if "HSPF2" not in efficiency_map.keys(): + if "HSPF" in efficiency_map.keys(): + efficiency_map["HSPF2"] = utils_equipment.calc_hspf2_from_hspf(efficiency_map["HSPF"], is_ducted) + else: + raise OCHREException( + f"Missing inputs for HVAC {hvac_type} efficiency, need HSPF to be provided." + ) # Get SHR is_heater = hvac_type == "Heating" @@ -1199,6 +1431,19 @@ def interpolate_seer2( else: shr = hvac.get("SensibleHeatFraction") + # Capacity retention + if is_heater and has_heat_pump: + if hvac.get("HeatingCapacity17F"): + capacity17f = convert(hvac.get("HeatingCapacity17F"), "Btu/hour", "W") + elif hvac.get("extension").get("HeatingCapacityFraction17F"): + capacity17f = hvac.get("extension").get("HeatingCapacityFraction17F") * capacity + else: # Default maximum capacity maintenance + if number_of_speeds in [1, 2]: + qm17full = 0.626 # Per RESNET HERS Addendum 82 + elif number_of_speeds == 4: + qm17full = 0.69 # NEEP database + capacity17f = qm17full * capacity + # Get auxiliary power (fans, pumps, etc.) air flow rate hvac_ext = hvac.get("extension", {}) if name == "Boiler": @@ -1222,8 +1467,8 @@ def interpolate_seer2( "Equipment Name": name, "Fuel": fuel.capitalize(), "Capacity (W)": capacity, - "EIR (-)": 1 / cop, - "Rated Efficiency": efficiency_string, + "EIR (-)": None, # Placeholder + "Rated Efficiency": efficiency_map, # Includes the defaulted SEER2/EER2/HSPF2, etc. "SHR (-)": shr, "Conditioned Space Fraction (-)": space_fraction, "Number of Speeds (-)": number_of_speeds, @@ -1232,7 +1477,7 @@ def interpolate_seer2( # Add startup capacity degradation factor for AC and heat pumps if has_heat_pump or not is_heater: - c_d = utils_equipment.calc_c_d(is_heater, name, cop, number_of_speeds) + c_d = utils_equipment.calc_c_d(name, number_of_speeds) out["Startup Capacity Degradation (-)"] = c_d # Get HVAC setpoints, optional @@ -1263,60 +1508,7 @@ def interpolate_seer2( } ) - if has_heat_pump and hvac_type == "Cooling": # Other hvac types e.g. central AC? - if heat_pump.get("CoolingDetailedPerformanceData") is not None: - cooling_detailed_performance_data = heat_pump.get( - "CoolingDetailedPerformanceData" - ) - cooling_performance = get_detailed_performance_data( - cooling_detailed_performance_data - ) - if "nominal_COP" in cooling_performance[95.0].keys(): - print(1 / cop) - cop = cooling_performance[95.0]["nominal_COP"] # override default COP - print(1 / cop) - out.update({"EIR (-)": 1 / cop}) - if "nominal_capacity" in cooling_performance[95.0].keys(): - if ( - abs(capacity - cooling_performance[95.0]["nominal_capacity"]) - > capacity * 0.01 - ): - raise OCHREException( - "Cooling nominal capacity inputs not consistent. Check CoolingCapacity and CoolingDetailedPerformanceData." - ) - out["CoolingDetailedPerformance"] = cooling_performance - else: - out["CoolingDetailedPerformance"] = ( - set_default_cooling_detailed_performance( - number_of_speeds, seer2, eer2, c_d, cop, capacity - ) - ) - - if has_heat_pump and hvac_type == "Heating": # Other hvac types e.g. central AC? - # TODO: else? Right now we'd keep things as is, but we might want to change default to match changes Yueyue made in OS-HPXML - if heat_pump.get("HeatingDetailedPerformanceData") is not None: - heating_detailed_performance_data = heat_pump.get( - "HeatingDetailedPerformanceData" - ) - heating_performance = get_detailed_performance_data( - heating_detailed_performance_data - ) - if "nominal_COP" in heating_performance[47.0].keys(): - cop = heating_performance[47.0]["nominal_COP"] # override default COP - out.update({"EIR (-)": 1 / cop}) - if "nominal_capacity" in heating_performance[47.0].keys(): - if ( - abs(capacity - heating_performance[47.0]["nominal_capacity"]) - > capacity * 0.01 - ): - raise OCHREException( - "Heating nominal capacity inputs not consistent. Check HeatingCapacity and HeatingDetailedPerformanceData." - ) - out["HeatingDetailedPerformance"] = heating_performance - else: - # TODO: Add default heating performance data - pass - + lct = None # Heat pump min temperature used for detailed performance data if has_heat_pump and hvac_type == "Heating": backup_fuel = heat_pump.get("BackupSystemFuel") backup_capacity = heat_pump.get("BackupHeatingCapacity", 0) @@ -1333,6 +1525,7 @@ def interpolate_seer2( heat_pump.get("BackupHeatingSwitchoverTemperature", 40), ) er_lockout_temp = convert(er_lockout_temp, "degF", "degC") + lct = er_lockout_temp if er_lockout_temp else hp_lockout_temp if backup_capacity: if backup_fuel != "electricity": print( @@ -1348,6 +1541,73 @@ def interpolate_seer2( } ) + if name in detailed_performance_sys_types: + if hvac_type == "Cooling": + rated_temp = utils_equipment.AIR_SOURCE_COOL_RATED_ODB + if heat_pump.get("CoolingDetailedPerformanceData") is not None: + cooling_detailed_performance_data = heat_pump.get( + "CoolingDetailedPerformanceData" + ) + out["CoolingDetailedPerformance"] = get_detailed_performance_data( + cooling_detailed_performance_data + ) + else: + out["CoolingDetailedPerformance"] = ( + set_default_cooling_detailed_performance( + number_of_speeds, efficiency_map["SEER2"], efficiency_map["EER2"], c_d, capacity + ) + ) + if "nominal_COP" in out["CoolingDetailedPerformance"][rated_temp].keys(): + efficiency_map["COP"] = out["CoolingDetailedPerformance"][rated_temp]["nominal_COP"] + if "nominal_capacity" in out["CoolingDetailedPerformance"][rated_temp].keys(): + if ( + abs(capacity - out["CoolingDetailedPerformance"][rated_temp]["nominal_capacity"]) + > capacity * 0.01 + ): + raise OCHREException( + "Cooling nominal capacity inputs not consistent. Check CoolingCapacity and CoolingDetailedPerformanceData." + ) + elif hvac_type == "Heating": + if heat_pump.get("HeatingDetailedPerformanceData") is not None: + heating_detailed_performance_data = heat_pump.get( + "HeatingDetailedPerformanceData" + ) + out["HeatingDetailedPerformance"] = get_detailed_performance_data( + heating_detailed_performance_data + ) + else: + out["HeatingDetailedPerformance"] = ( + set_default_heating_detailed_performance( + number_of_speeds, efficiency_map["HSPF2"], capacity17f / capacity, capacity, lct + ) + ) + + rated_temp = utils_equipment.AIR_SOURCE_HEAT_RATED_ODB + print(out["HeatingDetailedPerformance"].keys()) + if "nominal_COP" in out["HeatingDetailedPerformance"][rated_temp].keys(): + efficiency_map["COP"] = out["HeatingDetailedPerformance"][rated_temp]["nominal_COP"] + if "nominal_capacity" in out["HeatingDetailedPerformance"][rated_temp].keys(): + if ( + abs(capacity - out["HeatingDetailedPerformance"][rated_temp]["nominal_capacity"]) + > capacity * 0.01 + ): + raise OCHREException( + "Heating nominal capacity inputs not consistent. Check HeatingCapacity and HeatingDetailedPerformanceData." + ) + print(efficiency_map) + + if "COP" in efficiency_map.keys(): + out.update({"EIR (-)": 1 / efficiency_map["COP"]}) + elif "EER2" in efficiency_map.keys(): + out.update({"EIR (-)": 1 / convert(efficiency_map["EER2"], "Btu/hour", "W")}) + elif "Percent" in efficiency_map.keys(): + out.update({"EIR (-)": 1 / efficiency_map["Percent"]}) + elif "AFUE" in efficiency_map.keys(): + out.update({"EIR (-)": 1 / efficiency_map["AFUE"]}) + else: + raise OCHREException( + "Efficiency COP not provided or calculated." + ) # Get duct info for calculating DSE distribution = hvac_all.get("HVACDistribution", {}) distribution_type = distribution.get("DistributionSystemType", {}) From ff55f71d43ad3c04395ff7fd2fb29ae781f431af Mon Sep 17 00:00:00 2001 From: Yueyue Zhou Date: Fri, 30 Jan 2026 19:51:05 -0700 Subject: [PATCH 07/14] refactor of detailed performance data structure, sort data, fix efficiency errors, update a few variables in HVAC.py --- ochre/Equipment/HVAC.py | 78 +++++++++++--------- ochre/utils/equipment.py | 4 +- ochre/utils/hpxml.py | 152 +++++++++++++++++++++++---------------- 3 files changed, 139 insertions(+), 95 deletions(-) diff --git a/ochre/Equipment/HVAC.py b/ochre/Equipment/HVAC.py index e97825fd..c4389b96 100644 --- a/ochre/Equipment/HVAC.py +++ b/ochre/Equipment/HVAC.py @@ -55,7 +55,8 @@ def __init__(self, envelope_model=None, use_ideal_capacity=None, **kwargs): self.capacity_list = [0] + kwargs['Capacity (W)'] # rated capacities by speed, in W else: self.capacity_list = [0, kwargs['Capacity (W)']] - assert (np.diff(self.capacity_list) > 0).all() + # Nominal and max can be the same from NEEP database + assert (np.diff(self.capacity_list) >= 0).all() self.capacity = self.capacity_list[self.speed_idx] self.capacity_ideal = self.capacity # capacity to maintain setpoint, for ideal equipment, in W self.capacity_max = self.capacity_list[-1] # varies for dynamic equipment, in W @@ -686,43 +687,56 @@ def __init__(self, control_type='Time', **kwargs): min_time_in_low = kwargs.get('Minimum Low Time (minutes)', 5) min_time_in_high = kwargs.get('Minimum High Time (minutes)', 5) self.min_time_in_speed = [dt.timedelta(minutes=min_time_in_low), dt.timedelta(minutes=min_time_in_high)] + self.detailed_performance_data_htg = kwargs.get('HeatingDetailedPerformance', None) + self.detailed_performance_data_clg = kwargs.get('CoolingDetailedPerformance', None) # startup capacity degradation parameters self.startup_cap_mult = 1.0 # multiplier, unitless self.c_d = kwargs.get("Startup Capacity Degradation (-)", 0.0) # degradation factor, unitless - # Load biquadratic parameters from file - only keep those with the correct speed type - if not kwargs.get('Disable HVAC Biquadratics', False): - self.biquad_params = self.initialize_biquad_params(**kwargs) + if self.detailed_performance_data_htg: + # TODO: implement detailed performance curves + rated_dp = kwargs['HeatingDetailedPerformance'][utils_equipment.AIR_SOURCE_HEAT_RATED_ODB] + kwargs['Capacity (W)'] = [speed_data['capacity'] for speed_data in rated_dp.values()] + kwargs['EIR (-)'] = [1 / speed_data['COP'] for speed_data in rated_dp.values()] + kwargs['SHR (-)'] = [0.708] * len(kwargs['Capacity (W)']) + elif self.detailed_performance_data_clg: + rated_dp = kwargs['CoolingDetailedPerformance'][utils_equipment.AIR_SOURCE_COOL_RATED_ODB] + kwargs['Capacity (W)'] = [speed_data['capacity'] for speed_data in rated_dp.values()] + kwargs['EIR (-)'] = [1 / speed_data['COP'] for speed_data in rated_dp.values()] + kwargs['SHR (-)'] = [0.708] * len(kwargs['Capacity (W)']) else: - self.biquad_params = None - - # Load multispeed parameters from file - if self.n_speeds > 1: - rated_efficiency = kwargs.get('Rated Efficiency', '(Unknown Efficiency)') - multispeed_file = kwargs.get('multispeed_file', 'HVAC Multispeed Parameters.csv') - df_speed = load_csv(multispeed_file) - # Convert string to efficiency numbers, find the closest match - numeric_pattern = r"([-+]?(?:\d*\.?\d+))" - rated_efficiency_float = float(re.search(numeric_pattern, rated_efficiency).group()) - df_speed['Temp_Efficiency_Float'] = pd.to_numeric( - df_speed['HVAC Efficiency'].str.extract(numeric_pattern)[0]) - speed_params_subset = df_speed.loc[(df_speed['HVAC Name'] == self.name) & - (df_speed['Number of Speeds'] == self.n_speeds)] - closest_match_index = (speed_params_subset['Temp_Efficiency_Float'] - rated_efficiency_float).abs().idxmin() - df_speed = df_speed.drop(columns=['Temp_Efficiency_Float']) - speed_params = df_speed.loc[[closest_match_index]] - if not len(speed_params): - raise OCHREException(f'Cannot find multispeed parameters for {self.n_speeds}-speed {rated_efficiency} {self.name}') - assert len(speed_params) == 1 - speed_params = speed_params.iloc[0].to_dict() - - # update multispeed arguments (capacity ratios, air flow ratio, EIR, SHR) - kwargs['Capacity (W)'] = [kwargs['Capacity (W)'] * speed_params[f'Capacity Ratio {i + 1}'] - for i in range(self.n_speeds)] - kwargs['EIR (-)'] = [1 / speed_params[f'COP {i + 1}'] for i in range(self.n_speeds)] - kwargs['SHR (-)'] = [speed_params[f'SHR {i + 1}'] for i in range(self.n_speeds)] - kwargs['SHR (-)'] = [shr if not np.isnan(shr) else 1 for shr in kwargs['SHR (-)']] + # Load biquadratic parameters from file - only keep those with the correct speed type + if not kwargs.get('Disable HVAC Biquadratics', False): + self.biquad_params = self.initialize_biquad_params(**kwargs) + else: + self.biquad_params = None + # Load multispeed parameters from file + if self.n_speeds > 1: + rated_efficiency = kwargs.get('Rated Efficiency', '(Unknown Efficiency)') + multispeed_file = kwargs.get('multispeed_file', 'HVAC Multispeed Parameters.csv') + df_speed = load_csv(multispeed_file) + # Convert string to efficiency numbers, find the closest match + numeric_pattern = r"([-+]?(?:\d*\.?\d+))" + rated_efficiency_float = float(re.search(numeric_pattern, rated_efficiency).group()) + df_speed['Temp_Efficiency_Float'] = pd.to_numeric( + df_speed['HVAC Efficiency'].str.extract(numeric_pattern)[0]) + speed_params_subset = df_speed.loc[(df_speed['HVAC Name'] == self.name) & + (df_speed['Number of Speeds'] == self.n_speeds)] + closest_match_index = (speed_params_subset['Temp_Efficiency_Float'] - rated_efficiency_float).abs().idxmin() + df_speed = df_speed.drop(columns=['Temp_Efficiency_Float']) + speed_params = df_speed.loc[[closest_match_index]] + if not len(speed_params): + raise OCHREException(f'Cannot find multispeed parameters for {self.n_speeds}-speed {rated_efficiency} {self.name}') + assert len(speed_params) == 1 + speed_params = speed_params.iloc[0].to_dict() + + # update multispeed arguments (capacity ratios, air flow ratio, EIR, SHR) + kwargs['Capacity (W)'] = [kwargs['Capacity (W)'] * speed_params[f'Capacity Ratio {i + 1}'] + for i in range(self.n_speeds)] + kwargs['EIR (-)'] = [1 / speed_params[f'COP {i + 1}'] for i in range(self.n_speeds)] + kwargs['SHR (-)'] = [speed_params[f'SHR {i + 1}'] for i in range(self.n_speeds)] + kwargs['SHR (-)'] = [shr if not np.isnan(shr) else 1 for shr in kwargs['SHR (-)']] super().__init__(**kwargs) diff --git a/ochre/utils/equipment.py b/ochre/utils/equipment.py index 719a3a1b..b4f2f822 100644 --- a/ochre/utils/equipment.py +++ b/ochre/utils/equipment.py @@ -441,7 +441,7 @@ def calc_c_d(name, number_of_speeds): c_d = 0.22 elif number_of_speeds in [1, 2]: c_d = 0.08 - elif number_of_speeds == 4: + elif number_of_speeds == 3: c_d = 0.4 else: c_d = 0.0 # Do no capacity degradation at startup, since this isn't on/off equipment @@ -454,7 +454,7 @@ def calc_eer2_from_seer2(seer2, number_of_speeds): return min(0.73 * seer2 + 1.47, seer2) elif number_of_speeds == 2: return min(0.63 * seer2 + 2.34, seer2) - elif number_of_speeds == 4: + elif number_of_speeds == 3: return min(0.31 * seer2 + 6.45, seer2) diff --git a/ochre/utils/hpxml.py b/ochre/utils/hpxml.py index e5e9cda5..f411c6f5 100644 --- a/ochre/utils/hpxml.py +++ b/ochre/utils/hpxml.py @@ -951,21 +951,20 @@ def get_detailed_performance_data(detailed_performance_data): ) # not sure format of this out_temp = round(convert(float(detailed_performance_data[n]["OutdoorTemperature"]), "degF", "degC"), 1 ) + speed = detailed_performance_data[n]["CapacityDescription"] if out_temp not in performance.keys(): performance[out_temp] = {} + if speed not in performance[out_temp].keys(): + performance[out_temp][speed] = {} capacity_w = convert( float(detailed_performance_data[n]["Capacity"]), "Btu/hour", "W" ) cop = float(detailed_performance_data[n]["Efficiency"]["Value"]) - performance[out_temp][ - f"{detailed_performance_data[n]['CapacityDescription']}_capacity" - ] = round(capacity_w, 2) + performance[out_temp][speed]['capacity'] = round(capacity_w, 2) - performance[out_temp][ - f"{detailed_performance_data[n]['CapacityDescription']}_COP" - ] = round(cop, 2) + performance[out_temp][speed]['COP'] = round(cop, 2) return performance @@ -1087,7 +1086,7 @@ def interpolate_hspf2( cop5min = capacity5min / interp2(5.0, 17.0, 47.0, capacity17min / cop17min, capacity47min / cop47min) else: cop5min = interp2(5.0, 17.0, 47.0, cop17min, cop47min) # Arbitrary - elif number_of_speeds == 4: + elif number_of_speeds == 3: qr47full = 0.908 # Q47full/Q47max qr47min = 0.272 # Q47min/Q47max qr17full = 0.817 # Q17full/Q17max @@ -1174,41 +1173,53 @@ def interpolate_hspf2( tempLCT = round(lct,1) heating_performance[tempLCT] = {} if capacityLCTmin is not None: - heating_performance[tempLCT]["minimum_capacity"] = round(capacityLCTmin, 2) - heating_performance[tempLCT]["minimum_COP"] = round(copLCTmin, 2) + heating_performance[tempLCT]["minimum"] = {} + heating_performance[tempLCT]["minimum"]["capacity"] = round(capacityLCTmin, 2) + heating_performance[tempLCT]["minimum"]["COP"] = round(copLCTmin, 2) if capacityLCTfull is not None: - heating_performance[tempLCT]["nominal_capacity"] = round(capacityLCTfull, 2) - heating_performance[tempLCT]["nominal_COP"] = round(copLCTfull, 2) + heating_performance[tempLCT]["nominal"] = {} + heating_performance[tempLCT]["nominal"]["capacity"] = round(capacityLCTfull, 2) + heating_performance[tempLCT]["nominal"]["COP"] = round(copLCTfull, 2) if capacityLCTmax is not None: - heating_performance[tempLCT]["maximum_capacity"] = round(capacityLCTmax, 2) - heating_performance[tempLCT]["maximum_COP"] = round(copLCTmax, 2) + heating_performance[tempLCT]["maximum"] = {} + heating_performance[tempLCT]["maximum"]["capacity"] = round(capacityLCTmax, 2) + heating_performance[tempLCT]["maximum"]["COP"] = round(copLCTmax, 2) if capacity5min is not None: - heating_performance[temp5]["minimum_capacity"] = round(capacity5min, 2) - heating_performance[temp5]["minimum_COP"] = round(cop5min, 2) + heating_performance[temp5]["minimum"] = {} + heating_performance[temp5]["minimum"]["capacity"] = round(capacity5min, 2) + heating_performance[temp5]["minimum"]["COP"] = round(cop5min, 2) if capacity5full is not None: - heating_performance[temp5]["nominal_capacity"] = round(capacity5full, 2) - heating_performance[temp5]["nominal_COP"] = round(cop5full, 2) + heating_performance[temp5]["nominal"] = {} + heating_performance[temp5]["nominal"]["capacity"] = round(capacity5full, 2) + heating_performance[temp5]["nominal"]["COP"] = round(cop5full, 2) if capacity5max is not None: - heating_performance[temp5]["maximum_capacity"] = round(capacity5max, 2) - heating_performance[temp5]["maximum_COP"] = round(cop5max, 2) + heating_performance[temp5]["maximum"] = {} + heating_performance[temp5]["maximum"]["capacity"] = round(capacity5max, 2) + heating_performance[temp5]["maximum"]["COP"] = round(cop5max, 2) if capacity17min is not None: - heating_performance[temp17]["minimum_capacity"] = round(capacity17min, 2) - heating_performance[temp17]["minimum_COP"] = round(cop17min, 2) + heating_performance[temp17]["minimum"] = {} + heating_performance[temp17]["minimum"]["capacity"] = round(capacity17min, 2) + heating_performance[temp17]["minimum"]["COP"] = round(cop17min, 2) if capacity17full is not None: - heating_performance[temp17]["nominal_capacity"] = round(capacity17full, 2) - heating_performance[temp17]["nominal_COP"] = round(cop17full, 2) + heating_performance[temp17]["nominal"] = {} + heating_performance[temp17]["nominal"]["capacity"] = round(capacity17full, 2) + heating_performance[temp17]["nominal"]["COP"] = round(cop17full, 2) if capacity17max is not None: - heating_performance[temp17]["maximum_capacity"] = round(capacity17max, 2) - heating_performance[temp17]["maximum_COP"] = round(cop17max, 2) + heating_performance[temp17]["maximum"] = {} + heating_performance[temp17]["maximum"]["capacity"] = round(capacity17max, 2) + heating_performance[temp17]["maximum"]["COP"] = round(cop17max, 2) if capacity47min is not None: - heating_performance[temp47]["minimum_capacity"] = round(capacity47min, 2) - heating_performance[temp47]["minimum_COP"] = round(cop47min, 2) + heating_performance[temp47]["minimum"] = {} + heating_performance[temp47]["minimum"]["capacity"] = round(capacity47min, 2) + heating_performance[temp47]["minimum"]["COP"] = round(cop47min, 2) if capacity47full is not None: - heating_performance[temp47]["nominal_capacity"] = round(capacity47full, 2) - heating_performance[temp47]["nominal_COP"] = round(cop47full, 2) + heating_performance[temp47]["nominal"] = {} + heating_performance[temp47]["nominal"]["capacity"] = round(capacity47full, 2) + heating_performance[temp47]["nominal"]["COP"] = round(cop47full, 2) if capacity47max is not None: - heating_performance[temp47]["maximum_capacity"] = round(capacity47max, 2) - heating_performance[temp47]["maximum_COP"] = round(cop47max, 2) + heating_performance[temp47]["maximum"] = {} + heating_performance[temp47]["maximum"]["capacity"] = round(capacity47max, 2) + heating_performance[temp47]["maximum"]["COP"] = round(cop47max, 2) return heating_performance def set_default_cooling_detailed_performance( @@ -1282,7 +1293,7 @@ def interpolate_seer2( # 95F min speed capacity95min = capacity95full * cool_capacity_ratios[0] cop95min = cop82min / eirm95full - elif number_of_speeds == 4: + elif number_of_speeds == 3: qr95full = 0.934 # Q95full/Q95max qm95max = 0.940 # Q95max/Q82max qm95min = 0.948 # Q95min/Q82min @@ -1319,25 +1330,43 @@ def interpolate_seer2( temp95 = utils_equipment.AIR_SOURCE_COOL_RATED_ODB cooling_performance[temp95] = {} if capacity82min is not None: - cooling_performance[temp82]["minimum_capacity"] = round(capacity82min, 2) - cooling_performance[temp82]["minimum_COP"] = round(cop82min, 2) + cooling_performance[temp82]["minimum"] = {} + cooling_performance[temp82]["minimum"]["capacity"] = round(capacity82min, 2) + cooling_performance[temp82]["minimum"]["COP"] = round(cop82min, 2) if capacity82full is not None: - cooling_performance[temp82]["nominal_capacity"] = round(capacity82full, 2) - cooling_performance[temp82]["nominal_COP"] = round(cop82full, 2) + cooling_performance[temp82]["nominal"] = {} + cooling_performance[temp82]["nominal"]["capacity"] = round(capacity82full, 2) + cooling_performance[temp82]["nominal"]["COP"] = round(cop82full, 2) if capacity82max is not None: - cooling_performance[temp82]["maximum_capacity"] = round(capacity82max, 2) - cooling_performance[temp82]["maximum_COP"] = round(cop82max, 2) + cooling_performance[temp82]["maximum"] = {} + cooling_performance[temp82]["maximum"]["capacity"] = round(capacity82max, 2) + cooling_performance[temp82]["maximum"]["COP"] = round(cop82max, 2) if capacity95min is not None: - cooling_performance[temp95]["minimum_capacity"] = round(capacity95min, 2) - cooling_performance[temp95]["minimum_COP"] = round(cop95min, 2) + cooling_performance[temp95]["minimum"] = {} + cooling_performance[temp95]["minimum"]["capacity"] = round(capacity95min, 2) + cooling_performance[temp95]["minimum"]["COP"] = round(cop95min, 2) if capacity82full is not None: - cooling_performance[temp95]["nominal_capacity"] = round(capacity95full, 2) - cooling_performance[temp95]["nominal_COP"] = round(cop95full, 2) + cooling_performance[temp95]["nominal"] = {} + cooling_performance[temp95]["nominal"]["capacity"] = round(capacity95full, 2) + cooling_performance[temp95]["nominal"]["COP"] = round(cop95full, 2) if capacity82max is not None: - cooling_performance[temp95]["maximum_capacity"] = round(capacity95max, 2) - cooling_performance[temp95]["maximum_COP"] = round(cop95max, 2) + cooling_performance[temp95]["maximum"] = {} + cooling_performance[temp95]["maximum"]["capacity"] = round(capacity95max, 2) + cooling_performance[temp95]["maximum"]["COP"] = round(cop95max, 2) return cooling_performance + def sort_detailed_performance(performance): + sorted_performance = {} + speed_order = ["minimum", "nominal", "maximum"] + for temp in sorted(performance.keys()): + # First level sorting on temperature + sorted_performance[temp] = performance[temp] + # Second level sorting on speed + sorted_performance[temp] = { + speed: sorted_performance[temp][speed] for speed in speed_order if speed in sorted_performance[temp] + } + return sorted_performance + # Get HVAC HPXML parameters from HVAC Plant or Heat Pump system = hvac_all.get("HVACPlant", {}).get(f"{hvac_type}System") heat_pump = hvac_all.get("HVACPlant", {}).get("HeatPump") @@ -1365,7 +1394,7 @@ def interpolate_seer2( speed_options = { "single stage": 1, "two stage": 2, - "variable speed": 4, + "variable speed": 3, } if has_heat_pump or hvac_type == "Cooling": # TODO: ERROR checking for unsupported system types. @@ -1383,10 +1412,12 @@ def interpolate_seer2( # Efficiency input and conversion efficiency_map = {} - efficiency = hvac[f"Annual{hvac_type}Efficiency"] + efficiencies = hvac.get(f"Annual{hvac_type}Efficiency", {}) valid_efficiency_units = {"Cooling": ["SEER", "SEER2", "EER", "EER2"], "Heating": ["Percent", "AFUE", "HSPF", "HSPF2"]} - for n in range(len(efficiency)): + # handle one or more efficiency elements + efficiencies = efficiencies if isinstance(efficiencies, list) else [efficiencies] + for efficiency in efficiencies: # Store all the efficiencies in the map # Separate logic for cooling and heating units if efficiency["Units"] in valid_efficiency_units[hvac_type]: @@ -1440,7 +1471,7 @@ def interpolate_seer2( else: # Default maximum capacity maintenance if number_of_speeds in [1, 2]: qm17full = 0.626 # Per RESNET HERS Addendum 82 - elif number_of_speeds == 4: + elif number_of_speeds == 3: qm17full = 0.69 # NEEP database capacity17f = qm17full * capacity @@ -1548,20 +1579,20 @@ def interpolate_seer2( cooling_detailed_performance_data = heat_pump.get( "CoolingDetailedPerformanceData" ) - out["CoolingDetailedPerformance"] = get_detailed_performance_data( + cooling_detailed_performance_data_dict = get_detailed_performance_data( cooling_detailed_performance_data ) else: - out["CoolingDetailedPerformance"] = ( + cooling_detailed_performance_data_dict = ( set_default_cooling_detailed_performance( number_of_speeds, efficiency_map["SEER2"], efficiency_map["EER2"], c_d, capacity ) ) - if "nominal_COP" in out["CoolingDetailedPerformance"][rated_temp].keys(): - efficiency_map["COP"] = out["CoolingDetailedPerformance"][rated_temp]["nominal_COP"] - if "nominal_capacity" in out["CoolingDetailedPerformance"][rated_temp].keys(): + out["CoolingDetailedPerformance"] = sort_detailed_performance(cooling_detailed_performance_data_dict) + if "nominal" in out["CoolingDetailedPerformance"][rated_temp].keys(): + efficiency_map["COP"] = out["CoolingDetailedPerformance"][rated_temp]["nominal"]["COP"] if ( - abs(capacity - out["CoolingDetailedPerformance"][rated_temp]["nominal_capacity"]) + abs(capacity - out["CoolingDetailedPerformance"][rated_temp]["nominal"]["capacity"]) > capacity * 0.01 ): raise OCHREException( @@ -1572,23 +1603,22 @@ def interpolate_seer2( heating_detailed_performance_data = heat_pump.get( "HeatingDetailedPerformanceData" ) - out["HeatingDetailedPerformance"] = get_detailed_performance_data( + heating_detailed_performance_data_dict = get_detailed_performance_data( heating_detailed_performance_data ) else: - out["HeatingDetailedPerformance"] = ( + heating_detailed_performance_data_dict = ( set_default_heating_detailed_performance( number_of_speeds, efficiency_map["HSPF2"], capacity17f / capacity, capacity, lct ) ) - + out["HeatingDetailedPerformance"] = sort_detailed_performance(heating_detailed_performance_data_dict) rated_temp = utils_equipment.AIR_SOURCE_HEAT_RATED_ODB print(out["HeatingDetailedPerformance"].keys()) - if "nominal_COP" in out["HeatingDetailedPerformance"][rated_temp].keys(): - efficiency_map["COP"] = out["HeatingDetailedPerformance"][rated_temp]["nominal_COP"] - if "nominal_capacity" in out["HeatingDetailedPerformance"][rated_temp].keys(): + if "nominal" in out["HeatingDetailedPerformance"][rated_temp].keys(): + efficiency_map["COP"] = out["HeatingDetailedPerformance"][rated_temp]["nominal"]["COP"] if ( - abs(capacity - out["HeatingDetailedPerformance"][rated_temp]["nominal_capacity"]) + abs(capacity - out["HeatingDetailedPerformance"][rated_temp]["nominal"]["capacity"]) > capacity * 0.01 ): raise OCHREException( From e6e0355c3b478eb27bd9c71d3d52865a62f8863e Mon Sep 17 00:00:00 2001 From: Yueyue Zhou Date: Fri, 20 Feb 2026 13:35:43 -0700 Subject: [PATCH 08/14] dp processing --- ochre/Equipment/HVAC.py | 69 ++++++++++++++++- ochre/utils/equipment.py | 157 +++++++++++++++++++++++++++++++++++++++ ochre/utils/hpxml.py | 139 ++++++++++++++++++---------------- 3 files changed, 299 insertions(+), 66 deletions(-) diff --git a/ochre/Equipment/HVAC.py b/ochre/Equipment/HVAC.py index c4389b96..30e05995 100644 --- a/ochre/Equipment/HVAC.py +++ b/ochre/Equipment/HVAC.py @@ -60,6 +60,12 @@ def __init__(self, envelope_model=None, use_ideal_capacity=None, **kwargs): self.capacity = self.capacity_list[self.speed_idx] self.capacity_ideal = self.capacity # capacity to maintain setpoint, for ideal equipment, in W self.capacity_max = self.capacity_list[-1] # varies for dynamic equipment, in W + self.nominal_index = { + 1: 1, # single speed equipment only has one speed, [0, capacity] + 2: 2, # for two speed equipment, nominal capacity is at high speed, [0, low_capacity, high_capacity] + 3: 2, # for variable speed equipment, nominal capacity is at middle speed, [0, low_capacity, middle_capacity, high_capacity] + }[len(self.capacity_list) - 1] # subtract 1 because capacity_list includes 0 for off speed + self.capacity_nominal = self.capacity_list[self.nominal_index] # varies for dynamic equipment, in W self.capacity_min = kwargs.get('Minimum Capacity (W)', 0) # for ideal equipment, in W self.space_fraction = kwargs.get('Conditioned Space Fraction (-)', 1.0) self.delivered_heat = 0 # in W, total sensible heat gain, excluding duct losses @@ -98,15 +104,15 @@ def __init__(self, envelope_model=None, use_ideal_capacity=None, **kwargs): delta_t = temp_setpoint - convert(cool_supply_temp, 'degF', 'degC') self.flow_rate_list = [cap / 1000 / rho_air / cp_air / delta_t for cap in self.capacity_list] # in m^3/s else: - # Use nominal flow rates, values taken from ResStock (see hvac.rb line 2623) - cfm_per_ton = 350 if self.is_heater else 312 + # Use nominal flow rates, values taken from ResStock (see hvac.rb) + cfm_per_ton = utils_equipment.get_rated_cfm_per_ton(self.name) ratio = convert(cfm_per_ton, 'cubic_feet/min/refrigeration_ton', 'm^3/s/W') self.flow_rate_list = [ratio * capacity for capacity in self.capacity_list] # in m^3/s rated_flow_rate = max(self.flow_rate_list) # Fan power parameters - rated_fan_power = kwargs['Rated Auxiliary Power (W)'] - self.fan_power_per_flow_rate = rated_fan_power / rated_flow_rate + self.fan_power_rated = kwargs['Rated Auxiliary Power (W)'] + self.fan_power_per_flow_rate = self.fan_power_rated / rated_flow_rate self.fan_power_list = [self.fan_power_per_flow_rate * rate for rate in self.flow_rate_list] # in W self.fan_power = 0 # in W self.fan_power_max = max(self.fan_power_list) @@ -122,6 +128,7 @@ def __init__(self, envelope_model=None, use_ideal_capacity=None, **kwargs): f' ({len(speed_list) - 1})') # Duct location and distribution system efficiency (DSE) + self.is_ducted = kwargs.get('Ducted') is not None ducts = kwargs.get('Ducts', {'DSE (-)': 1}) self.duct_dse = ducts.get('DSE (-)') # Duct distribution system efficiency self.duct_zone = self.envelope_model.zones.get(ducts.get('Zone')) @@ -687,8 +694,11 @@ def __init__(self, control_type='Time', **kwargs): min_time_in_low = kwargs.get('Minimum Low Time (minutes)', 5) min_time_in_high = kwargs.get('Minimum High Time (minutes)', 5) self.min_time_in_speed = [dt.timedelta(minutes=min_time_in_low), dt.timedelta(minutes=min_time_in_high)] + self.datapoint_by_speed_htg = None + self.datapoint_by_speed_clg = None self.detailed_performance_data_htg = kwargs.get('HeatingDetailedPerformance', None) self.detailed_performance_data_clg = kwargs.get('CoolingDetailedPerformance', None) + self.fan_motor_type = kwargs.get('Fan Motor Type') # 'PSC' or 'BPM' # startup capacity degradation parameters self.startup_cap_mult = 1.0 # multiplier, unitless @@ -740,6 +750,12 @@ def __init__(self, control_type='Time', **kwargs): super().__init__(**kwargs) + print(self.capacity_list) + if self.detailed_performance_data_htg: + self.datapoint_by_speed_htg = utils_equipment.process_detailed_performance_data(self.detailed_performance_data_htg, 'Heating', self.capacity_nominal, self.name, self.capacity_list, self.fan_power_per_flow_rate, self.fan_motor_type, self.is_ducted) + if self.detailed_performance_data_clg: + self.datapoint_by_speed_clg = utils_equipment.process_detailed_performance_data(self.detailed_performance_data_clg, 'Cooling', self.capacity_nominal, self.name, self.capacity_list, self.fan_power_per_flow_rate, self.fan_motor_type, self.is_ducted) + # Check EIR and print warning if too low if self.eir_max > 0.5: self.warn("Low EIR:", self.eir_max, "(at full capacity)") @@ -864,6 +880,51 @@ def run_thermostat_control(self, setpoint=None): else: raise OCHREException('Incompatible number of speeds for dynamic equipment:', self.n_speeds) + def calculate_performance_curves(self, param, speed_idx, flow_fraction=1, part_load_ratio=1, biquadratic=True): + # runs biquadratic equation or detailed performance interpolation for EIR or capacity given the speed index + # param is 'cap' or 'eir' + + # get rated value based on speed + if param == 'cap': + rated = self.capacity_list[speed_idx] + elif param == 'eir': + rated = self.eir_list[speed_idx] + else: + raise OCHREException('Unknown biquadratic parameter:', param) + + if speed_idx == 0 or self.biquad_params is None: + return rated + + + # use coil input wet bulb for cooling, dry bulb for heating; ambient dry bulb for both + t_in = self.coil_input_db if self.is_heater else self.coil_input_wb + t_ext_db = self.current_schedule['Ambient Dry Bulb (C)'] + + # clip temperatures, flow fraction, part load ratio to stay within bounds + t_in = min(max(t_in, params['min_Twb']), params['max_Twb']) + t_ext_db = min(max(t_ext_db, params['min_Tdb']), params['max_Tdb']) + flow_fraction = min(max(flow_fraction, params['min_ff']), params['max_ff']) + + if biquadratic: + # get biquadratic parameters for current speed + params = self.biquad_params[speed_idx] + + # create vectors based on temperature, flow fraction, and plr + t_list = np.array([1, t_in, t_in ** 2, t_ext_db, t_ext_db ** 2, t_in * t_ext_db], dtype=float) + t_ratio = np.dot(t_list, params[param + '_t']) + + ff_list = np.array([1, flow_fraction, flow_fraction ** 2], dtype=float) + ff_ratio = np.dot(ff_list, params[param + '_ff']) + + plf_list = np.array([1, part_load_ratio, part_load_ratio ** 2], dtype=float) + plf_ratio = np.dot(plf_list, params[param + '_plr']) + plf_ratio = min(max(plf_ratio, params['min_plf']), params['max_plf']) + else: + if self.is_heater and self.detailed_performance_data_htg: + rated_dp = self.detailed_performance_data_htg[utils_equipment.AIR_SOURCE_HEAT_RATED_ODB] + + return rated * t_ratio * ff_ratio / plf_ratio + def calculate_biquadratic_param(self, param, speed_idx, flow_fraction=1, part_load_ratio=1): # runs biquadratic equation for EIR or capacity given the speed index # param is 'cap' or 'eir' diff --git a/ochre/utils/equipment.py b/ochre/utils/equipment.py index b4f2f822..a9a50e1f 100644 --- a/ochre/utils/equipment.py +++ b/ochre/utils/equipment.py @@ -1,4 +1,5 @@ import math +from pprint import pprint import numpy as np import psychrolib @@ -48,6 +49,11 @@ AIR_SOURCE_COOL_RATED_OWB = round(convert(75.0, "degF", "degC"), 1) # Rated outdoor wetbulb for air-source systems, cooling AIR_SOURCE_COOL_RATED_IDB = round(convert(80.0, "degF", "degC"), 1) # Rated indoor drybulb for air-source systems, cooling AIR_SOURCE_COOL_RATED_IWB = round(convert(67.0, "degF", "degC"), 1) # Rated indoor wetbulb for air-source systems, cooling +HPXML_SPEED_DESCRIPTION_MINIMUM = 'minimum' +HPXML_SPEED_DESCRIPTION_NOMINAL = 'nominal' +HPXML_SPEED_DESCRIPTION_MAXIMUM = 'maximum' +HPXML_HVAC_FAN_MOTOR_TYPE_BPM = 'BPM' +HPXML_HVAC_FAN_MOTOR_TYPE_PSC = 'PSC' def get_duct_info(ducts, zones, boundaries, construction, location, **kwargs): # Get zone type from duct_zone and zone info @@ -658,6 +664,157 @@ def calculate_mass_flow_rate(DBin, Win, P, flow): mfr = flow * rho_in return mfr +def process_detailed_performance_data(detailed_performance_data, mode, nominal_capacity, system_type, capacity_list, fan_power_per_flow_rate, fan_motor_type, is_ducted): + datapoints_by_speed = {} + for outtemp, speed_data in detailed_performance_data.items(): + for speed_description, datapoints in speed_data.items(): + if speed_description not in [HPXML_SPEED_DESCRIPTION_MINIMUM, HPXML_SPEED_DESCRIPTION_NOMINAL, HPXML_SPEED_DESCRIPTION_MAXIMUM]: + continue + if speed_description not in datapoints_by_speed: + datapoints_by_speed[speed_description] = [] + dp = {} + dp['outdoor_temperature'] = outtemp + dp['net_capacity'] = datapoints['capacity'] + dp['net_COP'] = datapoints['COP'] + datapoints_by_speed[speed_description].append(dp) + datapoints_by_speed = convert_datapoint_net_to_gross(datapoints_by_speed, mode, nominal_capacity, system_type, capacity_list, fan_power_per_flow_rate, fan_motor_type, is_ducted) + #extrapolate_datapoints(datapoints_by_speed mode, hp_min_temp, weather_temp) + datapoints_by_speed = correct_ft_cap_eir(datapoints_by_speed, mode) + for speed_description, datapoints in datapoints_by_speed.items(): + for dp in datapoints: + print(f'Speed: {speed_description}, ODB: {dp["outdoor_temperature"]}, IDB: {dp["indoor_temperature"]}, Gross Capacity: {dp["gross_capacity"]}, Gross COP: {dp["gross_COP"]}') + +def extrapolate_datapoints(datapoints_by_speed, mode, hp_min_temp, weather_temp, heating_capacity, cooling_capacity, system_type, capacity_ratio, fan_power_rated, fan_motor_type, is_ducted): + for speed_description, datapoints in datapoints_by_speed.items(): + user_odbs = sorted(datapoints.keys()) + outdoor_dry_bulbs = [] + if mode == "Cooling": + # Max cooling ODB temperature + max_odb = weather_temp + if max_odb > max(user_odbs): + outdoor_dry_bulbs.append([max_odb, None, None]) + +def calculate_biquadratic(x, y, c): + if len(c) != 6: + raise OCHREException('Error: There must be 6 coefficients in a biquadratic polynomial') + + z = c[0] + c[1] * x + c[2] * x**2 + c[3] * y + c[4] * y**2 + c[5] * y * x + return z + +def correct_ft_cap_eir(datapoints_by_speed, mode): + if mode == "Cooling": + rated_t_i = AIR_SOURCE_COOL_RATED_IWB + indoor_t = [convert(57.0, 'degF', 'degC'), rated_t_i, convert(72.0, 'degF', 'degC')] + # Cutler curve used for introducing indoor temperature dependence to capacity and EIR. + cap_ft_spec_ss = [3.717717741, -0.09918866, 0.000964488, 0.005887776, -0.000012808, -0.000132822] + eir_ft_spec_ss = [-3.400341169, 0.135184783, -0.001037932, -0.007852322, 0.000183438, -0.000142548] + else: + rated_t_i = AIR_SOURCE_HEAT_RATED_IDB + indoor_t = [convert(60.0, 'degF', 'degC'), rated_t_i, convert(80.0, 'degF', 'degC')] + # Cutler curve used for introducing indoor temperature dependence to capacity and EIR. + cap_ft_spec_ss = [0.568706266, -0.000747282, -0.0000103432, 0.00945408, 0.000050812, -0.00000677828] + eir_ft_spec_ss = [0.722917608, 0.003520184, 0.000143097, -0.005760341, 0.000141736, -0.000216676] + + for speed_description, datapoints in datapoints_by_speed.items(): + for dp in datapoints: + if mode == "Cooling": + dp['indoor_wetbulb'] = rated_t_i + else: + dp['indoor_temperature'] = rated_t_i + + # table lookup output values + for speed_description, datapoints in datapoints_by_speed.items(): + array_tmp = [] + for t_i in indoor_t: + if t_i == rated_t_i: + continue + data_tmp = [] + for dp in datapoints: + dp_new = dp.copy() + if mode == "Cooling": + dp_new['indoor_wetbulb'] = t_i + # Cooling variations shall be held constant for Tiwb less than 57°F and greater than 72°F, and for Todb less than 75°F + curve_t_o = max(dp_new['outdoor_temperature'], convert(75.0, 'degF', 'degC')) + else: + dp_new['indoor_temperature'] = t_i + curve_t_o = dp_new['outdoor_temperature'] + cap_ft_curve_output = calculate_biquadratic(convert(t_i, 'degC', 'degF'), convert(curve_t_o, 'degC', 'degF'), cap_ft_spec_ss) + cap_ft_curve_output_rated = calculate_biquadratic(convert(rated_t_i, 'degC', 'degF'), convert(curve_t_o, 'degC', 'degF'), cap_ft_spec_ss) + cap_correction_factor = cap_ft_curve_output / cap_ft_curve_output_rated + # corrected capacity hash, with two temperature independent variables + dp_new['gross_capacity'] *= cap_correction_factor + # EIR FT curve output + eir_ft_curve_output = calculate_biquadratic(convert(t_i, 'degC', 'degF'), convert(curve_t_o, 'degC', 'degF'), eir_ft_spec_ss) + eir_ft_curve_output_rated = calculate_biquadratic(convert(rated_t_i, 'degC', 'degF'), convert(curve_t_o, 'degC', 'degF'), eir_ft_spec_ss) + eir_correction_factor = eir_ft_curve_output / eir_ft_curve_output_rated + dp_new['gross_COP'] /= eir_correction_factor + data_tmp.append(dp_new) + array_tmp.append(data_tmp) + for new_data in array_tmp: + datapoints.extend(new_data) + return datapoints_by_speed + +def convert_datapoint_net_to_gross(datapoints_by_speed, mode, nominal_capacity, system_type, capacity_list, fan_power_per_flow_rate, fan_motor_type, is_ducted): + speed_index = { + HPXML_SPEED_DESCRIPTION_MINIMUM: 1, + HPXML_SPEED_DESCRIPTION_NOMINAL: 2, + HPXML_SPEED_DESCRIPTION_MAXIMUM: 3 + } # add 1 to speed index to include off speed in capacity list + # Calculate rated cfm based on cooling per AHRI + rated_cfm_per_ton = get_rated_cfm_per_ton(system_type) + rated_cfm = rated_cfm_per_ton * convert(nominal_capacity, 'W', 'refrigeration_ton') + print(f'Nominal capacity: {nominal_capacity} W, {convert(nominal_capacity, "W", "refrigeration_ton")} tons') + print(f'Rated CFM based on nominal capacity and system type: {rated_cfm} cfm') + if rated_cfm < 3: # Resort to heating if we get a HP w/ only heating + raise OCHREException(f'Rated CFM is too low ({rated_cfm} cfm). Check if the nominal capacity and system type are correct.') + + # data structure: datapoints_by_speed[speed_description][outtemp]['net_capacity'] = 1000 + for speed_description, datapoints in datapoints_by_speed.items(): + for dp in datapoints: + fan_cfm = calc_rated_airflow(nominal_capacity, rated_cfm_per_ton, 'cubic_feet/min') * (capacity_list[speed_index[speed_description]] / nominal_capacity) + fan_ratio = fan_cfm / rated_cfm + watts_per_cfm = convert(fan_power_per_flow_rate, 'W/(m^3/s)', 'W/(cubic_feet/min)') + fan_power = calculate_fan_power(watts_per_cfm * rated_cfm, fan_ratio, fan_motor_type, is_ducted) + dp['gross_capacity'], dp['gross_COP'] = convert_net_to_gross_capacity_cop(mode, dp['net_capacity'], fan_power, dp['net_COP']) + dp['input_power'] = dp['net_capacity'] / dp['net_COP'] # W + dp['gross_input_power'] = dp['gross_capacity'] / dp['gross_COP'] # W + return datapoints_by_speed + +def convert_net_to_gross_capacity_cop(mode, net_capacity, fan_power, net_cop = None): + if mode == 'Cooling': + gross_cap_watts = net_capacity + fan_power + else: + gross_cap_watts = net_capacity - fan_power + if net_cop is not None: + net_power = net_capacity / net_cop + gross_power = net_power - fan_power + gross_cop = gross_cap_watts / gross_power + gross_capacity = gross_cap_watts + return gross_capacity, gross_cop + +def calc_rated_airflow(net_rated_capacity, rated_cfm_per_ton, output_units): + return convert(net_rated_capacity, 'W', 'refrigeration_ton') * convert(rated_cfm_per_ton, 'cubic_feet/min', output_units) + +def calculate_fan_power(max_fan_power, fan_ratio, fan_motor_type, is_ducted): + if fan_motor_type is None: + # For system types that fan_motor_type is not specified, the fan_ratio should be 1 + if fan_ratio != 1.0 and max_fan_power != 0.0: + raise IOError( 'Missing fan motor type for systems where more than one speed is modeled') + + return max_fan_power + else: + # Based on RESNET HERS Addendum 82 + if fan_motor_type == HPXML_HVAC_FAN_MOTOR_TYPE_BPM: + pow = 2.75 if is_ducted else 3 + return max_fan_power * (fan_ratio**pow) + elif fan_motor_type == HPXML_HVAC_FAN_MOTOR_TYPE_PSC: + return max_fan_power * fan_ratio * (0.3 * fan_ratio + 0.7) + +def get_rated_cfm_per_ton(system_type): + if system_type == 'Room AC': + return 312 + else: + return 400 def calculate_shr(DBin, Win, P, Q, flow, Ao): """ diff --git a/ochre/utils/hpxml.py b/ochre/utils/hpxml.py index f411c6f5..1faf4ec7 100644 --- a/ochre/utils/hpxml.py +++ b/ochre/utils/hpxml.py @@ -1173,53 +1173,53 @@ def interpolate_hspf2( tempLCT = round(lct,1) heating_performance[tempLCT] = {} if capacityLCTmin is not None: - heating_performance[tempLCT]["minimum"] = {} - heating_performance[tempLCT]["minimum"]["capacity"] = round(capacityLCTmin, 2) - heating_performance[tempLCT]["minimum"]["COP"] = round(copLCTmin, 2) + heating_performance[tempLCT][utils_equipment.HPXML_SPEED_DESCRIPTION_MINIMUM] = {} + heating_performance[tempLCT][utils_equipment.HPXML_SPEED_DESCRIPTION_MINIMUM]["capacity"] = round(capacityLCTmin, 2) + heating_performance[tempLCT][utils_equipment.HPXML_SPEED_DESCRIPTION_MINIMUM]["COP"] = round(copLCTmin, 2) if capacityLCTfull is not None: - heating_performance[tempLCT]["nominal"] = {} - heating_performance[tempLCT]["nominal"]["capacity"] = round(capacityLCTfull, 2) - heating_performance[tempLCT]["nominal"]["COP"] = round(copLCTfull, 2) + heating_performance[tempLCT][utils_equipment.HPXML_SPEED_DESCRIPTION_NOMINAL] = {} + heating_performance[tempLCT][utils_equipment.HPXML_SPEED_DESCRIPTION_NOMINAL]["capacity"] = round(capacityLCTfull, 2) + heating_performance[tempLCT][utils_equipment.HPXML_SPEED_DESCRIPTION_NOMINAL]["COP"] = round(copLCTfull, 2) if capacityLCTmax is not None: - heating_performance[tempLCT]["maximum"] = {} - heating_performance[tempLCT]["maximum"]["capacity"] = round(capacityLCTmax, 2) - heating_performance[tempLCT]["maximum"]["COP"] = round(copLCTmax, 2) + heating_performance[tempLCT][utils_equipment.HPXML_SPEED_DESCRIPTION_MAXIMUM] = {} + heating_performance[tempLCT][utils_equipment.HPXML_SPEED_DESCRIPTION_MAXIMUM]["capacity"] = round(capacityLCTmax, 2) + heating_performance[tempLCT][utils_equipment.HPXML_SPEED_DESCRIPTION_MAXIMUM]["COP"] = round(copLCTmax, 2) if capacity5min is not None: - heating_performance[temp5]["minimum"] = {} - heating_performance[temp5]["minimum"]["capacity"] = round(capacity5min, 2) - heating_performance[temp5]["minimum"]["COP"] = round(cop5min, 2) + heating_performance[temp5][utils_equipment.HPXML_SPEED_DESCRIPTION_MINIMUM] = {} + heating_performance[temp5][utils_equipment.HPXML_SPEED_DESCRIPTION_MINIMUM]["capacity"] = round(capacity5min, 2) + heating_performance[temp5][utils_equipment.HPXML_SPEED_DESCRIPTION_MINIMUM]["COP"] = round(cop5min, 2) if capacity5full is not None: - heating_performance[temp5]["nominal"] = {} - heating_performance[temp5]["nominal"]["capacity"] = round(capacity5full, 2) - heating_performance[temp5]["nominal"]["COP"] = round(cop5full, 2) + heating_performance[temp5][utils_equipment.HPXML_SPEED_DESCRIPTION_NOMINAL] = {} + heating_performance[temp5][utils_equipment.HPXML_SPEED_DESCRIPTION_NOMINAL]["capacity"] = round(capacity5full, 2) + heating_performance[temp5][utils_equipment.HPXML_SPEED_DESCRIPTION_NOMINAL]["COP"] = round(cop5full, 2) if capacity5max is not None: - heating_performance[temp5]["maximum"] = {} - heating_performance[temp5]["maximum"]["capacity"] = round(capacity5max, 2) - heating_performance[temp5]["maximum"]["COP"] = round(cop5max, 2) + heating_performance[temp5][utils_equipment.HPXML_SPEED_DESCRIPTION_MAXIMUM] = {} + heating_performance[temp5][utils_equipment.HPXML_SPEED_DESCRIPTION_MAXIMUM]["capacity"] = round(capacity5max, 2) + heating_performance[temp5][utils_equipment.HPXML_SPEED_DESCRIPTION_MAXIMUM]["COP"] = round(cop5max, 2) if capacity17min is not None: - heating_performance[temp17]["minimum"] = {} - heating_performance[temp17]["minimum"]["capacity"] = round(capacity17min, 2) - heating_performance[temp17]["minimum"]["COP"] = round(cop17min, 2) + heating_performance[temp17][utils_equipment.HPXML_SPEED_DESCRIPTION_MINIMUM] = {} + heating_performance[temp17][utils_equipment.HPXML_SPEED_DESCRIPTION_MINIMUM]["capacity"] = round(capacity17min, 2) + heating_performance[temp17][utils_equipment.HPXML_SPEED_DESCRIPTION_MINIMUM]["COP"] = round(cop17min, 2) if capacity17full is not None: - heating_performance[temp17]["nominal"] = {} - heating_performance[temp17]["nominal"]["capacity"] = round(capacity17full, 2) - heating_performance[temp17]["nominal"]["COP"] = round(cop17full, 2) + heating_performance[temp17][utils_equipment.HPXML_SPEED_DESCRIPTION_NOMINAL] = {} + heating_performance[temp17][utils_equipment.HPXML_SPEED_DESCRIPTION_NOMINAL]["capacity"] = round(capacity17full, 2) + heating_performance[temp17][utils_equipment.HPXML_SPEED_DESCRIPTION_NOMINAL]["COP"] = round(cop17full, 2) if capacity17max is not None: - heating_performance[temp17]["maximum"] = {} - heating_performance[temp17]["maximum"]["capacity"] = round(capacity17max, 2) - heating_performance[temp17]["maximum"]["COP"] = round(cop17max, 2) + heating_performance[temp17][utils_equipment.HPXML_SPEED_DESCRIPTION_MAXIMUM] = {} + heating_performance[temp17][utils_equipment.HPXML_SPEED_DESCRIPTION_MAXIMUM]["capacity"] = round(capacity17max, 2) + heating_performance[temp17][utils_equipment.HPXML_SPEED_DESCRIPTION_MAXIMUM]["COP"] = round(cop17max, 2) if capacity47min is not None: - heating_performance[temp47]["minimum"] = {} - heating_performance[temp47]["minimum"]["capacity"] = round(capacity47min, 2) - heating_performance[temp47]["minimum"]["COP"] = round(cop47min, 2) + heating_performance[temp47][utils_equipment.HPXML_SPEED_DESCRIPTION_MINIMUM] = {} + heating_performance[temp47][utils_equipment.HPXML_SPEED_DESCRIPTION_MINIMUM]["capacity"] = round(capacity47min, 2) + heating_performance[temp47][utils_equipment.HPXML_SPEED_DESCRIPTION_MINIMUM]["COP"] = round(cop47min, 2) if capacity47full is not None: - heating_performance[temp47]["nominal"] = {} - heating_performance[temp47]["nominal"]["capacity"] = round(capacity47full, 2) - heating_performance[temp47]["nominal"]["COP"] = round(cop47full, 2) + heating_performance[temp47][utils_equipment.HPXML_SPEED_DESCRIPTION_NOMINAL] = {} + heating_performance[temp47][utils_equipment.HPXML_SPEED_DESCRIPTION_NOMINAL]["capacity"] = round(capacity47full, 2) + heating_performance[temp47][utils_equipment.HPXML_SPEED_DESCRIPTION_NOMINAL]["COP"] = round(cop47full, 2) if capacity47max is not None: - heating_performance[temp47]["maximum"] = {} - heating_performance[temp47]["maximum"]["capacity"] = round(capacity47max, 2) - heating_performance[temp47]["maximum"]["COP"] = round(cop47max, 2) + heating_performance[temp47][utils_equipment.HPXML_SPEED_DESCRIPTION_MAXIMUM] = {} + heating_performance[temp47][utils_equipment.HPXML_SPEED_DESCRIPTION_MAXIMUM]["capacity"] = round(capacity47max, 2) + heating_performance[temp47][utils_equipment.HPXML_SPEED_DESCRIPTION_MAXIMUM]["COP"] = round(cop47max, 2) return heating_performance def set_default_cooling_detailed_performance( @@ -1330,29 +1330,29 @@ def interpolate_seer2( temp95 = utils_equipment.AIR_SOURCE_COOL_RATED_ODB cooling_performance[temp95] = {} if capacity82min is not None: - cooling_performance[temp82]["minimum"] = {} - cooling_performance[temp82]["minimum"]["capacity"] = round(capacity82min, 2) - cooling_performance[temp82]["minimum"]["COP"] = round(cop82min, 2) + cooling_performance[temp82][utils_equipment.HPXML_SPEED_DESCRIPTION_MINIMUM] = {} + cooling_performance[temp82][utils_equipment.HPXML_SPEED_DESCRIPTION_MINIMUM]["capacity"] = round(capacity82min, 2) + cooling_performance[temp82][utils_equipment.HPXML_SPEED_DESCRIPTION_MINIMUM]["COP"] = round(cop82min, 2) if capacity82full is not None: - cooling_performance[temp82]["nominal"] = {} - cooling_performance[temp82]["nominal"]["capacity"] = round(capacity82full, 2) - cooling_performance[temp82]["nominal"]["COP"] = round(cop82full, 2) + cooling_performance[temp82][utils_equipment.HPXML_SPEED_DESCRIPTION_NOMINAL] = {} + cooling_performance[temp82][utils_equipment.HPXML_SPEED_DESCRIPTION_NOMINAL]["capacity"] = round(capacity82full, 2) + cooling_performance[temp82][utils_equipment.HPXML_SPEED_DESCRIPTION_NOMINAL]["COP"] = round(cop82full, 2) if capacity82max is not None: - cooling_performance[temp82]["maximum"] = {} - cooling_performance[temp82]["maximum"]["capacity"] = round(capacity82max, 2) - cooling_performance[temp82]["maximum"]["COP"] = round(cop82max, 2) + cooling_performance[temp82][utils_equipment.HPXML_SPEED_DESCRIPTION_MAXIMUM] = {} + cooling_performance[temp82][utils_equipment.HPXML_SPEED_DESCRIPTION_MAXIMUM]["capacity"] = round(capacity82max, 2) + cooling_performance[temp82][utils_equipment.HPXML_SPEED_DESCRIPTION_MAXIMUM]["COP"] = round(cop82max, 2) if capacity95min is not None: - cooling_performance[temp95]["minimum"] = {} - cooling_performance[temp95]["minimum"]["capacity"] = round(capacity95min, 2) - cooling_performance[temp95]["minimum"]["COP"] = round(cop95min, 2) - if capacity82full is not None: - cooling_performance[temp95]["nominal"] = {} - cooling_performance[temp95]["nominal"]["capacity"] = round(capacity95full, 2) - cooling_performance[temp95]["nominal"]["COP"] = round(cop95full, 2) - if capacity82max is not None: - cooling_performance[temp95]["maximum"] = {} - cooling_performance[temp95]["maximum"]["capacity"] = round(capacity95max, 2) - cooling_performance[temp95]["maximum"]["COP"] = round(cop95max, 2) + cooling_performance[temp95][utils_equipment.HPXML_SPEED_DESCRIPTION_MINIMUM] = {} + cooling_performance[temp95][utils_equipment.HPXML_SPEED_DESCRIPTION_MINIMUM]["capacity"] = round(capacity95min, 2) + cooling_performance[temp95][utils_equipment.HPXML_SPEED_DESCRIPTION_MINIMUM]["COP"] = round(cop95min, 2) + if capacity95full is not None: + cooling_performance[temp95][utils_equipment.HPXML_SPEED_DESCRIPTION_NOMINAL] = {} + cooling_performance[temp95][utils_equipment.HPXML_SPEED_DESCRIPTION_NOMINAL]["capacity"] = round(capacity95full, 2) + cooling_performance[temp95][utils_equipment.HPXML_SPEED_DESCRIPTION_NOMINAL]["COP"] = round(cop95full, 2) + if capacity95max is not None: + cooling_performance[temp95][utils_equipment.HPXML_SPEED_DESCRIPTION_MAXIMUM] = {} + cooling_performance[temp95][utils_equipment.HPXML_SPEED_DESCRIPTION_MAXIMUM]["capacity"] = round(capacity95max, 2) + cooling_performance[temp95][utils_equipment.HPXML_SPEED_DESCRIPTION_MAXIMUM]["COP"] = round(cop95max, 2) return cooling_performance def sort_detailed_performance(performance): @@ -1370,6 +1370,7 @@ def sort_detailed_performance(performance): # Get HVAC HPXML parameters from HVAC Plant or Heat Pump system = hvac_all.get("HVACPlant", {}).get(f"{hvac_type}System") heat_pump = hvac_all.get("HVACPlant", {}).get("HeatPump") + # FIXME: Support dual-fuel heat pumps in the future, which would require both heating system and heat pump inputs to be provided. if system and heat_pump: raise OCHREException( f"HVAC {hvac_type} system and heat pump cannot both be specified." @@ -1388,6 +1389,13 @@ def sort_detailed_performance(performance): # pilot = data.get('PilotLight', False) # pilot_rate = data.get('extension', {}).get('PilotLightBtuh', 500) fuel = hvac["HeatPumpFuel"] if has_heat_pump else hvac.get(f"{hvac_type}SystemFuel") + if has_heat_pump: + htg_capacity = convert(hvac["HeatingCapacity"], "Btu/hour", "W") + clg_capacity = convert(hvac["CoolingCapacity"], "Btu/hour", "W") + if abs(htg_capacity - clg_capacity) > 10: # Allow for small differences in capacity due to rounding + raise OCHREException( + f"Heating and cooling capacities must be the same for heat pump systems. Heating capacity: {htg_capacity} W, Cooling capacity: {clg_capacity} W." + ) capacity = convert(hvac[f"{hvac_type}Capacity"], "Btu/hour", "W") space_fraction = hvac.get(f"Fraction{hvac_type[:-3]}LoadServed", 1.0) # Get number of speeds @@ -1428,6 +1436,7 @@ def sort_detailed_performance(performance): ) is_ducted = bool(hvac_all.get("HVACDistribution", {}).get("DistributionSystemType", {}).get("AirDistribution")) + hvac_ext = hvac.get("extension", {}) detailed_performance_sys_types = ["air-to-air", "mini-split", "central air conditioner"] if name in detailed_performance_sys_types: # Default efficiencies that are missing from inputs @@ -1466,8 +1475,8 @@ def sort_detailed_performance(performance): if is_heater and has_heat_pump: if hvac.get("HeatingCapacity17F"): capacity17f = convert(hvac.get("HeatingCapacity17F"), "Btu/hour", "W") - elif hvac.get("extension").get("HeatingCapacityFraction17F"): - capacity17f = hvac.get("extension").get("HeatingCapacityFraction17F") * capacity + elif hvac_ext.get("HeatingCapacityFraction17F"): + capacity17f = hvac_ext.get("HeatingCapacityFraction17F") * capacity else: # Default maximum capacity maintenance if number_of_speeds in [1, 2]: qm17full = 0.626 # Per RESNET HERS Addendum 82 @@ -1476,7 +1485,6 @@ def sort_detailed_performance(performance): capacity17f = qm17full * capacity # Get auxiliary power (fans, pumps, etc.) air flow rate - hvac_ext = hvac.get("extension", {}) if name == "Boiler": # Note: ResStock assumes 2080 hours/year, see hvac.rb line 1754 (get_default_boiler_eae) # see also ANSI/RESNET/ICC 301-2019 Equation 4.4-5 @@ -1486,13 +1494,21 @@ def sort_detailed_performance(performance): elif "FanPowerWattsPerCFM" in hvac_ext: # Note: air flow rate is only used for non-dymanic HVAC models with fans, e.g., furnaces # airflow_cfm = hvac_ext.get(f'{hvac_type}AirflowCFM', 0) - cfm_per_ton = 350 if is_heater else 312 + cfm_per_ton = utils_equipment.get_rated_cfm_per_ton(name) power_per_cfm = hvac_ext.get("FanPowerWattsPerCFM", 0) aux_power = ( power_per_cfm * cfm_per_ton * convert(capacity, "W", "refrigeration_ton") ) else: aux_power = hvac_ext.get("FanPowerWatts", 0) + if "FanMotorType" in hvac_ext: + fan_motor_type = hvac_ext.get("FanMotorType") + elif name in detailed_performance_sys_types: + raise OCHREException( + f"Missing inputs for Fan Motor Type for system: {name}." + ) + else: + fan_motor_type = None out = { "Equipment Name": name, @@ -1504,6 +1520,7 @@ def sort_detailed_performance(performance): "Conditioned Space Fraction (-)": space_fraction, "Number of Speeds (-)": number_of_speeds, "Rated Auxiliary Power (W)": aux_power, + "Fan Motor Type": fan_motor_type, } # Add startup capacity degradation factor for AC and heat pumps @@ -1614,7 +1631,6 @@ def sort_detailed_performance(performance): ) out["HeatingDetailedPerformance"] = sort_detailed_performance(heating_detailed_performance_data_dict) rated_temp = utils_equipment.AIR_SOURCE_HEAT_RATED_ODB - print(out["HeatingDetailedPerformance"].keys()) if "nominal" in out["HeatingDetailedPerformance"][rated_temp].keys(): efficiency_map["COP"] = out["HeatingDetailedPerformance"][rated_temp]["nominal"]["COP"] if ( @@ -1624,7 +1640,6 @@ def sort_detailed_performance(performance): raise OCHREException( "Heating nominal capacity inputs not consistent. Check HeatingCapacity and HeatingDetailedPerformanceData." ) - print(efficiency_map) if "COP" in efficiency_map.keys(): out.update({"EIR (-)": 1 / efficiency_map["COP"]}) From f8b775bdd56bb20dff0ba61632f3b84e30c4dfd1 Mon Sep 17 00:00:00 2001 From: Yueyue Zhou Date: Tue, 17 Mar 2026 19:37:11 -0600 Subject: [PATCH 09/14] add performancecurve object, removes reading from tables --- ochre/Equipment/HVAC.py | 212 ++++++++++-------- ochre/defaults/HVAC Multispeed Parameters.csv | 40 ---- ochre/utils/equipment.py | 28 +-- 3 files changed, 130 insertions(+), 150 deletions(-) delete mode 100644 ochre/defaults/HVAC Multispeed Parameters.csv diff --git a/ochre/Equipment/HVAC.py b/ochre/Equipment/HVAC.py index 30e05995..e42cb9e1 100644 --- a/ochre/Equipment/HVAC.py +++ b/ochre/Equipment/HVAC.py @@ -12,7 +12,7 @@ SPEED_TYPES = { 1: 'Single', 2: 'Double', - 4: 'Variable', + 3: 'Variable', # 10: 'Mini-split Variable', # Note: MSHP model uses 4 speeds, not 10 } @@ -90,6 +90,8 @@ def __init__(self, envelope_model=None, use_ideal_capacity=None, **kwargs): self.shr = shr_list[self.speed_idx] # Air flow parameters + # TODO: Update flow rate lists based on rated CFM/ton + self.rated_cfm_per_ton = utils_equipment.get_rated_cfm_per_ton(self.name) if isinstance(self, DynamicHVAC): # calculate flow rates based on capacity and supply air temperature if self.is_heater: @@ -105,14 +107,13 @@ def __init__(self, envelope_model=None, use_ideal_capacity=None, **kwargs): self.flow_rate_list = [cap / 1000 / rho_air / cp_air / delta_t for cap in self.capacity_list] # in m^3/s else: # Use nominal flow rates, values taken from ResStock (see hvac.rb) - cfm_per_ton = utils_equipment.get_rated_cfm_per_ton(self.name) - ratio = convert(cfm_per_ton, 'cubic_feet/min/refrigeration_ton', 'm^3/s/W') + ratio = convert(self.rated_cfm_per_ton, 'cubic_feet/min/refrigeration_ton', 'm^3/s/W') self.flow_rate_list = [ratio * capacity for capacity in self.capacity_list] # in m^3/s - rated_flow_rate = max(self.flow_rate_list) + self.rated_flow_rate = utils_equipment.calc_rated_airflow(self.capacity_nominal, self.rated_cfm_per_ton, 'm^3/s') # in m^3/s # Fan power parameters self.fan_power_rated = kwargs['Rated Auxiliary Power (W)'] - self.fan_power_per_flow_rate = self.fan_power_rated / rated_flow_rate + self.fan_power_per_flow_rate = self.fan_power_rated / self.rated_flow_rate self.fan_power_list = [self.fan_power_per_flow_rate * rate for rate in self.flow_rate_list] # in W self.fan_power = 0 # in W self.fan_power_max = max(self.fan_power_list) @@ -166,8 +167,8 @@ def __init__(self, envelope_model=None, use_ideal_capacity=None, **kwargs): if self.is_heater: self.Ao_list = None elif isinstance(self, DynamicHVAC): - rated_dry_bulb = convert(80, 'degF', 'degC') # in degrees C - rated_wet_bulb = convert(67, 'degF', 'degC') # in degrees C + rated_dry_bulb = utils_equipment.AIR_SOURCE_COOL_RATED_IDB # in degrees C + rated_wet_bulb = utils_equipment.AIR_SOURCE_COOL_RATED_IWB # in degrees C rated_pressure = 101.3 # in kPa rated_w = psychrolib.GetHumRatioFromTWetBulb(rated_dry_bulb, rated_wet_bulb, rated_pressure * 1000) ao_data = zip(self.capacity_list[1:], self.flow_rate_list[1:], shr_list[1:]) @@ -683,6 +684,11 @@ class DynamicHVAC(HVAC): https://www1.eere.energy.gov/buildings/publications/pdfs/building_america/modeling_ac_heatpump.pdf Section 2.2.1, Equations 7-8 and 11-13 """ + SPEED_INDEX_MAP = { + 1: utils_equipment.HPXML_SPEED_DESCRIPTION_MINIMUM, + 2: utils_equipment.HPXML_SPEED_DESCRIPTION_NOMINAL, + 3: utils_equipment.HPXML_SPEED_DESCRIPTION_MAXIMUM + } # add 1 to speed index to include off speed in capacity list def __init__(self, control_type='Time', **kwargs): # Get number of speeds @@ -703,7 +709,20 @@ def __init__(self, control_type='Time', **kwargs): # startup capacity degradation parameters self.startup_cap_mult = 1.0 # multiplier, unitless self.c_d = kwargs.get("Startup Capacity Degradation (-)", 0.0) # degradation factor, unitless - + if kwargs.get('Disable HVAC Biquadratics', False): + self.eir_t = None + self.eir_ff = None + self.eir_plr = None + self.cap_t = None + self.cap_ff = None + else: + # Adding 1 for off speed in capacity list, but biquadratic curves only apply to on speeds + self.eir_t = [PerformanceCurve('biquadratic', 'temperature') for _ in range(self.n_speeds + 1)] + self.eir_ff = [PerformanceCurve('quadratic', 'fraction') for _ in range(self.n_speeds + 1)] + self.eir_plr = [PerformanceCurve('quadratic', 'fraction') for _ in range(self.n_speeds + 1)] + self.cap_t = [PerformanceCurve('biquadratic', 'temperature') for _ in range(self.n_speeds + 1)] + self.cap_ff = [PerformanceCurve('quadratic', 'fraction') for _ in range(self.n_speeds + 1)] + if self.detailed_performance_data_htg: # TODO: implement detailed performance curves rated_dp = kwargs['HeatingDetailedPerformance'][utils_equipment.AIR_SOURCE_HEAT_RATED_ODB] @@ -716,88 +735,48 @@ def __init__(self, control_type='Time', **kwargs): kwargs['EIR (-)'] = [1 / speed_data['COP'] for speed_data in rated_dp.values()] kwargs['SHR (-)'] = [0.708] * len(kwargs['Capacity (W)']) else: - # Load biquadratic parameters from file - only keep those with the correct speed type - if not kwargs.get('Disable HVAC Biquadratics', False): - self.biquad_params = self.initialize_biquad_params(**kwargs) + if self.name != 'Room AC': + raise OCHREException('Detailed performance data is required for dynamic HVAC equipment other than Room AC.') else: - self.biquad_params = None - # Load multispeed parameters from file - if self.n_speeds > 1: - rated_efficiency = kwargs.get('Rated Efficiency', '(Unknown Efficiency)') - multispeed_file = kwargs.get('multispeed_file', 'HVAC Multispeed Parameters.csv') - df_speed = load_csv(multispeed_file) - # Convert string to efficiency numbers, find the closest match - numeric_pattern = r"([-+]?(?:\d*\.?\d+))" - rated_efficiency_float = float(re.search(numeric_pattern, rated_efficiency).group()) - df_speed['Temp_Efficiency_Float'] = pd.to_numeric( - df_speed['HVAC Efficiency'].str.extract(numeric_pattern)[0]) - speed_params_subset = df_speed.loc[(df_speed['HVAC Name'] == self.name) & - (df_speed['Number of Speeds'] == self.n_speeds)] - closest_match_index = (speed_params_subset['Temp_Efficiency_Float'] - rated_efficiency_float).abs().idxmin() - df_speed = df_speed.drop(columns=['Temp_Efficiency_Float']) - speed_params = df_speed.loc[[closest_match_index]] - if not len(speed_params): - raise OCHREException(f'Cannot find multispeed parameters for {self.n_speeds}-speed {rated_efficiency} {self.name}') - assert len(speed_params) == 1 - speed_params = speed_params.iloc[0].to_dict() - - # update multispeed arguments (capacity ratios, air flow ratio, EIR, SHR) - kwargs['Capacity (W)'] = [kwargs['Capacity (W)'] * speed_params[f'Capacity Ratio {i + 1}'] - for i in range(self.n_speeds)] - kwargs['EIR (-)'] = [1 / speed_params[f'COP {i + 1}'] for i in range(self.n_speeds)] - kwargs['SHR (-)'] = [speed_params[f'SHR {i + 1}'] for i in range(self.n_speeds)] - kwargs['SHR (-)'] = [shr if not np.isnan(shr) else 1 for shr in kwargs['SHR (-)']] + # replace with Cutler curves, SI units + self.eir_t[1].set(coefficients=np.array([-0.350447695, 0.116809893, -0.00339950844, -0.001226088, 0.0006008094, -0.000466884], dtype=float)) + self.eir_ff[1].set(coefficients=np.array([1.0, 0.0, 0.0], dtype=float)) + self.eir_plr[1].set(coefficients=np.array([0.78, 0.22, 0], dtype=float), min_y=0.7, max_y=1.0) + self.cap_t[1].set(coefficients=np.array([1.557359706, -0.0744481692, 0.00309859668, 0.0014595786, -0.000041148, -0.00042671448], dtype=float)) + self.cap_ff[1].set(coefficients=np.array([1.0, 0.0, 0.0], dtype=float)) super().__init__(**kwargs) print(self.capacity_list) if self.detailed_performance_data_htg: - self.datapoint_by_speed_htg = utils_equipment.process_detailed_performance_data(self.detailed_performance_data_htg, 'Heating', self.capacity_nominal, self.name, self.capacity_list, self.fan_power_per_flow_rate, self.fan_motor_type, self.is_ducted) + self.datapoint_by_speed_htg = utils_equipment.process_detailed_performance_data(self.detailed_performance_data_htg, 'Heating', self.capacity_nominal, self.rated_cfm_per_ton, self.capacity_list, self.fan_power_per_flow_rate, self.fan_motor_type, self.is_ducted) + for index, eir_t_curve in enumerate(self.eir_t): + if index == 0: + continue # skip off speed + eir_t_curve.set(datapoints=self.datapoint_by_speed_htg[self.SPEED_INDEX_MAP[index]]) + for index, cap_t_curve in enumerate(self.cap_t): + if index == 0: + continue # skip off speed + cap_t_curve.set(datapoints=self.datapoint_by_speed_htg[self.SPEED_INDEX_MAP[index]]) if self.detailed_performance_data_clg: - self.datapoint_by_speed_clg = utils_equipment.process_detailed_performance_data(self.detailed_performance_data_clg, 'Cooling', self.capacity_nominal, self.name, self.capacity_list, self.fan_power_per_flow_rate, self.fan_motor_type, self.is_ducted) + self.datapoint_by_speed_clg = utils_equipment.process_detailed_performance_data(self.detailed_performance_data_clg, 'Cooling', self.capacity_nominal, self.rated_cfm_per_ton, self.capacity_list, self.fan_power_per_flow_rate, self.fan_motor_type, self.is_ducted) + for index, eir_t_curve in enumerate(self.eir_t): + if index == 0: + continue # skip off speed + eir_t_curve.set(datapoints=self.datapoint_by_speed_clg[self.SPEED_INDEX_MAP[index]]) + for index, cap_t_curve in enumerate(self.cap_t): + if index == 0: + continue # skip off speed + cap_t_curve.set(datapoints=self.datapoint_by_speed_clg[self.SPEED_INDEX_MAP[index]]) # Check EIR and print warning if too low if self.eir_max > 0.5: self.warn("Low EIR:", self.eir_max, "(at full capacity)") - def initialize_biquad_params(self, **kwargs): - if self.n_speeds not in SPEED_TYPES: - raise OCHREException('Unknown number of speeds ({}). Should be one of: {}'.format(self.n_speeds, - SPEED_TYPES)) - speed_type = SPEED_TYPES[self.n_speeds] - - biquadratic_file = kwargs.get('biquadratic_file', f'Biquadratic {self.name}.csv') - biquad_params = self.initialize_parameters(biquadratic_file, value_col=None, **kwargs) - biquad_params = biquad_params.loc[:, [col for col in biquad_params if speed_type == col.split('_')[0]]] - if len(biquad_params.columns) != self.n_speeds: - raise OCHREException(f'Number of speeds ({self.n_speeds}) does not match number of biquadratic ' - f'equations ({len(biquad_params.columns)})') - biquad_params = {idx + 1: { - 'eir_t': np.array([val[f'{x}_eir_t'] for x in 'abcdef'], dtype=float), - 'eir_ff': np.array([val[f'{x}_eir_ff'] for x in 'abc'], dtype=float), - 'eir_plr': np.array([val[f'{x}_eir_plr'] for x in 'abc'], dtype=float), - 'cap_t': np.array([val[f'{x}_cap_t'] for x in 'abcdef'], dtype=float), - 'cap_ff': np.array([val[f'{x}_cap_ff'] for x in 'abc'], dtype=float), - 'cap_plr': np.array([1, 0, 0], dtype=float), - 'min_Twb': val.get('min_Twb', -100), - 'max_Twb': val.get('max_Twb', 100), - 'min_Tdb': val.get('min_Tdb', -100), - 'max_Tdb': val.get('max_Tdb', 100), - 'min_ff': val.get('min_ff', 0), - 'max_ff': val.get('max_ff', 1), - 'min_plf': val.get('min_plf', 0.7), - 'max_plf': val.get('max_plf', 1)} - for idx, (col, val) in enumerate(biquad_params.items()) - } - if not biquad_params: - raise OCHREException(f'Biquadratic parameters not found for {speed_type} speed {self.name}.') - if kwargs.get('Disable HVAC Part Load Factor', False): # for minimal tests, disable PLF - for key in biquad_params: - biquad_params[key]['eir_plr'] = np.array([1, 0, 0], dtype=float) - - return biquad_params + for eir_plr_curve in self.eir_plr: + eir_plr_curve.set(coefficients=np.array([1, 0, 0], dtype=float), datapoints=None) def update_external_control(self, control_signal): # Options for external control signals: @@ -932,36 +911,43 @@ def calculate_biquadratic_param(self, param, speed_idx, flow_fraction=1, part_lo # get rated value based on speed if param == 'cap': rated = self.capacity_list[speed_idx] + if self.cap_t is None: + return rated + else: + curve_t = self.cap_t[speed_idx] + curve_ff = self.cap_ff[speed_idx] elif param == 'eir': rated = self.eir_list[speed_idx] + if self.eir_t is None: + return rated + else: + curve_t = self.eir_t[speed_idx] + curve_ff = self.eir_ff[speed_idx] + curve_plr = self.eir_plr[speed_idx] else: raise OCHREException('Unknown biquadratic parameter:', param) - if speed_idx == 0 or self.biquad_params is None: - return rated - - # get biquadratic parameters for current speed - params = self.biquad_params[speed_idx] - # use coil input wet bulb for cooling, dry bulb for heating; ambient dry bulb for both t_in = self.coil_input_db if self.is_heater else self.coil_input_wb t_ext_db = self.current_schedule['Ambient Dry Bulb (C)'] # clip temperatures, flow fraction, part load ratio to stay within bounds - t_in = min(max(t_in, params['min_Twb']), params['max_Twb']) - t_ext_db = min(max(t_ext_db, params['min_Tdb']), params['max_Tdb']) - flow_fraction = min(max(flow_fraction, params['min_ff']), params['max_ff']) + t_in = min(max(t_in, curve_t.min_x1), curve_t.max_x1) + t_ext_db = min(max(t_ext_db, curve_t.min_x2), curve_t.max_x2) + flow_fraction = min(max(flow_fraction, curve_ff.min_x1), curve_ff.max_x1) # create vectors based on temperature, flow fraction, and plr t_list = np.array([1, t_in, t_in ** 2, t_ext_db, t_ext_db ** 2, t_in * t_ext_db], dtype=float) - t_ratio = np.dot(t_list, params[param + '_t']) + t_ratio = np.dot(t_list, curve_t.coefficients) ff_list = np.array([1, flow_fraction, flow_fraction ** 2], dtype=float) - ff_ratio = np.dot(ff_list, params[param + '_ff']) + ff_ratio = np.dot(ff_list, curve_ff.coefficients) - plf_list = np.array([1, part_load_ratio, part_load_ratio ** 2], dtype=float) - plf_ratio = np.dot(plf_list, params[param + '_plr']) - plf_ratio = min(max(plf_ratio, params['min_plf']), params['max_plf']) + if param == 'eir': + part_load_ratio = min(max(part_load_ratio, curve_plr.min_x1), curve_plr.max_x1) + plf_list = np.array([1, part_load_ratio, part_load_ratio ** 2], dtype=float) + plf_ratio = np.dot(plf_list, curve_plr.coefficients) + plf_ratio = min(max(plf_ratio, curve_plr.min_y), curve_plr.max_y) return rated * t_ratio * ff_ratio / plf_ratio @@ -1491,3 +1477,49 @@ def calculate_power_and_heat(self): self.pan_heater_on = self.pan_heater_kw > 0 and t_ext < self.pan_heater_temp if self.pan_heater_on: self.electric_kw += self.pan_heater_kw * self.space_fraction + +class PerformanceCurve: + REQUIRED_COEFFS = { + "quadratic": 3, + "cubic": 4, + "biquadratic": 6, + } + def __init__(self, curve_type, variable_type): + self.curve_type = curve_type + self.variable_type = variable_type + self.coefficients = None + self.datapoints = None + if variable_type == 'temperature': + self.min_x1 = -100.0 + self.max_x1 = 100.0 + self.min_x2 = -100.0 + self.max_x2 = 100.0 + elif variable_type == 'fraction': + self.min_x1 = 0.0 + self.max_x1 = 1.0 + self.min_y = None + self.max_y = None + + def set(self, **kwargs): + for key, value in kwargs.items(): + if not hasattr(self, key): + raise OCHREException(f"Unknown attribute: {key}") + + if key == "coefficients": + required = self.REQUIRED_COEFFS.get(self.curve_type) + if required is not None and len(value) != required: + raise OCHREException( + f"{self.curve_type} curve requires {required} coefficients" + ) + + setattr(self, key, value) + + def evaluate(self, x): + if self.curve_type == 'quadratic': + a, b, c = self.coefficients + return a * x**2 + b * x + c + elif self.curve_type == 'cubic': + a, b, c, d = self.coefficients + return a * x**3 + b * x**2 + c * x + d + else: + raise OCHREException('Unknown curve type: {}'.format(self.curve_type)) \ No newline at end of file diff --git a/ochre/defaults/HVAC Multispeed Parameters.csv b/ochre/defaults/HVAC Multispeed Parameters.csv deleted file mode 100644 index c1f2f68c..00000000 --- a/ochre/defaults/HVAC Multispeed Parameters.csv +++ /dev/null @@ -1,40 +0,0 @@ -HVAC Name,HVAC Efficiency,Number of Speeds,Capacity Ratio 1,Air Flow Ratio 1,COP 1,Capacity Ratio 2,Air Flow Ratio 2,COP 2,Received From,SHR 1,SHR 2,Capacity Ratio 3,Air Flow Ratio 3,COP 3,Capacity Ratio 4,Air Flow Ratio 4,COP 4,SHR 3,SHR 4 -ASHP Cooler,15.05 SEER,1,1,1,4.295682561,,,,BEopt,0.736303132,,,,,,,,, -ASHP Cooler,16.0 SEER,2,0.72,0.86,4.33748,1,1,3.99889,testing_500 - bldg0000005,0.71597,0.72878,,,,,,,, -ASHP Cooler,17.0 SEER,2,0.72,0.86,4.64342,1,1,4.29226,testing_500 - bldg0000021,0.71597,0.72878,,,,,,,, -ASHP Cooler,17.05 SEER,2,0.72,0.86,4.64342,1,1,4.29226,BEopt,0.721851216,0.72878,,,,,,,, -ASHP Cooler,18.0 SEER,2,0.72,0.86,4.95399,1,1,4.59165,testing_500 - bldg0000036,0.71597,0.72878,,,,,,,, -ASHP Cooler,18.84 SEER,2,0.72,0.86,5.218551946,1,1,4.847971652,Manual,0.71597,0.72878,,,,,,,, -ASHP Cooler,19.05 SEER,2,0.72,0.86,5.285230483,1,1,4.912758049,BEopt,0.721851216,0.733635953,,,,,,,, -ASHP Cooler,20.0 SEER,4,0.36,0.42,4.892687266,0.51,0.54,4.7,Manual,0.721851216,0.8,0.67,0.68,4.5,1,1,4.3,0.79,0.78 -ASHP Cooler,22.0 SEER,4,0.36,0.42,5.92545,0.51,0.54,6.14696,testing_500 - bldg0000134,0.87282,0.80463,0.67,0.68,5.98083,1,1,5.53781,0.79453,0.78453 -ASHP Cooler,24.0 SEER,4,0.36,0.42,6.56537945,0.51,0.54,6.810810731,ResStock 2024.2,0.8728212,0.804631493,0.67,0.68,6.626737294,1,1,6.135874106,0.794526816,0.784532542 -ASHP Heater,8.82 HSPF,1,1,1,3.922475188,,,,BEopt,,,,,,,,,, -ASHP Heater,8.7 HSPF,2,0.72,0.8,4.12879,1,1,3.5847,testing_500 - bldg0000021,,,,,,,,,, -ASHP Heater,8.94 HSPF,2,0.72,0.8,4.28,1,1,3.77,BEopt,,,,,,,,,, -ASHP Heater,9.0 HSPF,2,0.72,0.8,4.31947,1,1,3.81925,testing_500 - bldg0000005,,,,,,,,,, -ASHP Heater,9.2 HSPF,2,0.72,0.8,4.47579,1,1,4.018063333,Manual,,,,,,,,,, -ASHP Heater,9.3 HSPF,2,0.72,0.8,4.51448,1,1,4.06822,testing_500 - bldg0000036,,,,,,,,,, -ASHP Heater,9.76 HSPF,2,0.72,0.8,4.82227956,1,1,4.48122751,Manual,,,,,,,,,, -ASHP Heater,9.06 HSPF,2,0.72,0.8,4.33,1,1,3.825,BEopt,,,,,,,,,, -ASHP Heater,9.65 HSPF,2,0.72,0.8,4.747672763,1,1,4.378770717,BEopt,,,,,,,,,, -ASHP Heater,9.88 HSPF,2,0.72,0.8,4.904418633,1,1,4.595836253,BEopt,,,,,,,,,, -ASHP Heater,10.0 HSPF,4,0.33,0.63,5.67112,0.56,0.76,4.84428,testing_500 - bldg0000134,,,1,1,4.09417,1.17,1.19,3.91175,, -ASHP Heater,10.47 HSPF,4,0.33,0.63,6.097467675,0.56,0.76,5.208464363,BEopt,,,1,1,4.401967043,1.17,1.19,4.2058363,, -ASHP Heater,11.0 HSPF,4,0.33,0.63,4.55,0.56,0.76,4.36,Manual,,,1,1,4.17,1.17,1.19,3.98,, -ASHP Heater,13.0 HSPF,4,0.33,0.63,9.552598512,0.56,0.76,8.159846183,ResStock 2024.2,,,1,1,6.896350005,1.17,1.19,6.589082265,, -MSHP Cooler,13.0 SEER,4,0.48889,0.52941,3.611863346,0.66667,0.64706,3.344915289,testing_500 - bldg0000120,0.86256,0.7987,0.84444,0.76471,3.077967231,1.2,1,2.811019174,0.75709,0.70255 -MSHP Cooler,14.5 SEER,4,0.48889,0.52941,4.36175,0.66667,0.64706,3.77512,testing_500 - bldg0000120,0.86256,0.7987,0.84444,0.76471,3.17313,1.2,1,2.34548,0.75709,0.70255 -MSHP Cooler,16.0 SEER,4,0.48889,0.52941,4.33748,0.66667,0.64706,3.66,Manual,0.86256,0.7987,0.84444,0.76,3.33,1.2,1,2.99,0.76,0.7 -MSHP Cooler,17.0 SEER,4,0.48889,0.52941,5.13687,0.66667,0.64706,4.44548,testing_500 - bldg0000378,0.86256,0.7987,0.84444,0.76471,3.73585,1.2,1,2.76069,0.75709,0.70255 -MSHP Cooler,20.0 SEER,4,0.48889,0.52941,4.892687266,0.66667,0.64706,4.696270366,testing_500 - bldg0000378,0.86256,0.7987,0.84444,0.76471,4.499853467,1.2,1,4.303436567,0.75709,0.70255 -MSHP Cooler,24.0 SEER,4,0.48889,0.52941,7.964232567,0.66667,0.64706,6.884431597,Manual,0.86256,0.7987,0.84444,0.76471,5.769690221,1.2,1,4.253150569,0.75709,0.70255 -MSHP Cooler,29.3 SEER,4,0.48889,0.52941,9.054697497,0.66667,0.64706,7.831549963,Manual,0.86256,0.7987,0.84444,0.76471,6.574710549,1.2,1,4.851991362,0.75709,0.70255 -MSHP Cooler,33.0 SEER,4,0.48889,0.52941,10.26831,0.66667,0.64706,8.87966,testing_500 - bldg0000183,0.86256,0.7987,0.84444,0.76471,7.45227,1.2,1,5.49732,0.75709,0.70255 -MSHP Heater,8.2 HSPF,4,0.4,0.55556,4.19929,0.6,0.66667,3.36672,testing_500 - bldg0000120,,,0.8,0.77778,3.08195,1.2,1,2.79114,, -MSHP Heater,9.2 HSPF,4,0.4,0.55556,5.14266,0.6,0.66667,3.96,Manual,,,0.8,0.77778,3.53,1.2,1,3.11,, -MSHP Heater,9.5 HSPF,4,0.4,0.55556,5.14266,0.6,0.66667,4.12161,testing_500 - bldg0000378,,,0.8,0.77778,3.77355,1.2,1,3.41917,, -MSHP Heater,11.0 HSPF,4,0.4,0.55556,4.55,0.6,0.66667,4.36,Manual,,,0.8,0.77778,4.17,1.2,1,3.98,, -MSHP Heater,13.0 HSPF,4,0.4,0.55556,7.893351479,0.6,0.66667,6.282661747,Manual,,,0.8,0.77778,5.758473142,1.2,1,5.2460162,, -MSHP Heater,13.3 HSPF,4,0.4,0.55556,7.74616,0.6,0.66667,6.20218,testing_500 - bldg0000183,,,0.8,0.77778,5.68075,1.2,1,5.15429,, -MSHP Heater,14.0 HSPF,4,0.4,0.55556,8.399982335,0.6,0.66667,6.724045293,Manual,,,0.8,0.77778,6.159368877,1.2,1,5.590475552,, diff --git a/ochre/utils/equipment.py b/ochre/utils/equipment.py index a9a50e1f..babc2e55 100644 --- a/ochre/utils/equipment.py +++ b/ochre/utils/equipment.py @@ -664,7 +664,7 @@ def calculate_mass_flow_rate(DBin, Win, P, flow): mfr = flow * rho_in return mfr -def process_detailed_performance_data(detailed_performance_data, mode, nominal_capacity, system_type, capacity_list, fan_power_per_flow_rate, fan_motor_type, is_ducted): +def process_detailed_performance_data(detailed_performance_data, mode, nominal_capacity, rated_cfm_per_ton, capacity_list, fan_power_per_flow_rate, fan_motor_type, is_ducted): datapoints_by_speed = {} for outtemp, speed_data in detailed_performance_data.items(): for speed_description, datapoints in speed_data.items(): @@ -677,22 +677,11 @@ def process_detailed_performance_data(detailed_performance_data, mode, nominal_c dp['net_capacity'] = datapoints['capacity'] dp['net_COP'] = datapoints['COP'] datapoints_by_speed[speed_description].append(dp) - datapoints_by_speed = convert_datapoint_net_to_gross(datapoints_by_speed, mode, nominal_capacity, system_type, capacity_list, fan_power_per_flow_rate, fan_motor_type, is_ducted) + datapoints_by_speed = convert_datapoint_net_to_gross(datapoints_by_speed, mode, nominal_capacity, rated_cfm_per_ton, capacity_list, fan_power_per_flow_rate, fan_motor_type, is_ducted) #extrapolate_datapoints(datapoints_by_speed mode, hp_min_temp, weather_temp) datapoints_by_speed = correct_ft_cap_eir(datapoints_by_speed, mode) - for speed_description, datapoints in datapoints_by_speed.items(): - for dp in datapoints: - print(f'Speed: {speed_description}, ODB: {dp["outdoor_temperature"]}, IDB: {dp["indoor_temperature"]}, Gross Capacity: {dp["gross_capacity"]}, Gross COP: {dp["gross_COP"]}') - -def extrapolate_datapoints(datapoints_by_speed, mode, hp_min_temp, weather_temp, heating_capacity, cooling_capacity, system_type, capacity_ratio, fan_power_rated, fan_motor_type, is_ducted): - for speed_description, datapoints in datapoints_by_speed.items(): - user_odbs = sorted(datapoints.keys()) - outdoor_dry_bulbs = [] - if mode == "Cooling": - # Max cooling ODB temperature - max_odb = weather_temp - if max_odb > max(user_odbs): - outdoor_dry_bulbs.append([max_odb, None, None]) + print(datapoints_by_speed) + return datapoints_by_speed def calculate_biquadratic(x, y, c): if len(c) != 6: @@ -754,15 +743,14 @@ def correct_ft_cap_eir(datapoints_by_speed, mode): datapoints.extend(new_data) return datapoints_by_speed -def convert_datapoint_net_to_gross(datapoints_by_speed, mode, nominal_capacity, system_type, capacity_list, fan_power_per_flow_rate, fan_motor_type, is_ducted): +def convert_datapoint_net_to_gross(datapoints_by_speed, mode, nominal_capacity, rated_cfm_per_ton, capacity_list, fan_power_per_flow_rate, fan_motor_type, is_ducted): speed_index = { HPXML_SPEED_DESCRIPTION_MINIMUM: 1, HPXML_SPEED_DESCRIPTION_NOMINAL: 2, HPXML_SPEED_DESCRIPTION_MAXIMUM: 3 } # add 1 to speed index to include off speed in capacity list # Calculate rated cfm based on cooling per AHRI - rated_cfm_per_ton = get_rated_cfm_per_ton(system_type) - rated_cfm = rated_cfm_per_ton * convert(nominal_capacity, 'W', 'refrigeration_ton') + rated_cfm = calc_rated_airflow(nominal_capacity, rated_cfm_per_ton, 'cubic_feet/min') print(f'Nominal capacity: {nominal_capacity} W, {convert(nominal_capacity, "W", "refrigeration_ton")} tons') print(f'Rated CFM based on nominal capacity and system type: {rated_cfm} cfm') if rated_cfm < 3: # Resort to heating if we get a HP w/ only heating @@ -771,8 +759,8 @@ def convert_datapoint_net_to_gross(datapoints_by_speed, mode, nominal_capacity, # data structure: datapoints_by_speed[speed_description][outtemp]['net_capacity'] = 1000 for speed_description, datapoints in datapoints_by_speed.items(): for dp in datapoints: - fan_cfm = calc_rated_airflow(nominal_capacity, rated_cfm_per_ton, 'cubic_feet/min') * (capacity_list[speed_index[speed_description]] / nominal_capacity) - fan_ratio = fan_cfm / rated_cfm + fan_cfm = rated_cfm * (capacity_list[speed_index[speed_description]] / nominal_capacity) + fan_ratio = fan_cfm / rated_cfm # equal to capacity ratio in this case, OS-HPXML could be different since the rated_cfm watts_per_cfm = convert(fan_power_per_flow_rate, 'W/(m^3/s)', 'W/(cubic_feet/min)') fan_power = calculate_fan_power(watts_per_cfm * rated_cfm, fan_ratio, fan_motor_type, is_ducted) dp['gross_capacity'], dp['gross_COP'] = convert_net_to_gross_capacity_cop(mode, dp['net_capacity'], fan_power, dp['net_COP']) From cae81cb8ccc9d8d1b37360858bd66670b44d658c Mon Sep 17 00:00:00 2001 From: Yueyue Zhou Date: Mon, 23 Mar 2026 15:05:42 -0600 Subject: [PATCH 10/14] first run of detailed performance test file --- ochre/Dwelling.py | 1 + ochre/Equipment/HVAC.py | 385 +++++++++++++++++++++++++++++---------- ochre/utils/equipment.py | 85 ++++++--- ochre/utils/hpxml.py | 10 +- 4 files changed, 356 insertions(+), 125 deletions(-) diff --git a/ochre/Dwelling.py b/ochre/Dwelling.py index dfda961e..337f4f5d 100644 --- a/ochre/Dwelling.py +++ b/ochre/Dwelling.py @@ -138,6 +138,7 @@ def __init__( # Create all equipment self.equipment = {} for equipment_name, equipment_args in equipment_dict.items(): + print(equipment_name) cls = equipment_args.pop("equipment_class", EQUIPMENT_BY_NAME.get(equipment_name)) equipment_args = {**sim_args, **equipment_args} eq = cls(name=equipment_name, **equipment_args) diff --git a/ochre/Equipment/HVAC.py b/ochre/Equipment/HVAC.py index e42cb9e1..e3125a5d 100644 --- a/ochre/Equipment/HVAC.py +++ b/ochre/Equipment/HVAC.py @@ -90,33 +90,33 @@ def __init__(self, envelope_model=None, use_ideal_capacity=None, **kwargs): self.shr = shr_list[self.speed_idx] # Air flow parameters - # TODO: Update flow rate lists based on rated CFM/ton + self.fan_motor_type = kwargs.get('Fan Motor Type') # 'PSC' or 'BPM' self.rated_cfm_per_ton = utils_equipment.get_rated_cfm_per_ton(self.name) - if isinstance(self, DynamicHVAC): - # calculate flow rates based on capacity and supply air temperature - if self.is_heater: - # temp_setpoint = min(max(kwargs['Min Setpoint (C)'], 15), 24) - temp_setpoint = 20 # in degC, from ASHRAE Standard 152, 6.3.1 Indoor Air Conditions - delta_t = convert(105, 'degF', 'degC') - temp_setpoint - else: - # interpolate to get cooling supply temp based on rated SHR, must be between 54-58 F - # temp_setpoint = min(max(kwargs['Max Setpoint (C)'], 18), 27) - temp_setpoint = 25.5 # from ASHRAE Standard 152, 6.3.1 Indoor Air Conditions - cool_supply_temp = np.clip(54 + (58 - 54) * (shr_list[-1] - 0.8) / (0.85 - 0.80), 54, 58) - delta_t = temp_setpoint - convert(cool_supply_temp, 'degF', 'degC') - self.flow_rate_list = [cap / 1000 / rho_air / cp_air / delta_t for cap in self.capacity_list] # in m^3/s + if kwargs['Design Airflow (CFM)'] is not None: + self.design_airflow = convert(kwargs['Design Airflow (CFM)'], 'cubic_feet/min', 'm^3/s') # in m^3/s + self.design_cfm_per_ton = kwargs['Design Airflow (CFM)'] / convert(self.capacity_nominal, 'W', 'refrigeration_ton') else: - # Use nominal flow rates, values taken from ResStock (see hvac.rb) - ratio = convert(self.rated_cfm_per_ton, 'cubic_feet/min/refrigeration_ton', 'm^3/s/W') - self.flow_rate_list = [ratio * capacity for capacity in self.capacity_list] # in m^3/s + self.design_cfm_per_ton = utils_equipment.get_design_cfm_per_ton(self.name) + self.design_airflow = convert(self.design_cfm_per_ton * convert(self.capacity_nominal, 'W', 'refrigeration_ton'), 'cubic_feet/min', 'm^3/s') # in m^3/s + + self.flow_rate_list = [self.design_airflow * capacity / self.capacity_nominal for capacity in self.capacity_list] # in m^3/s self.rated_flow_rate = utils_equipment.calc_rated_airflow(self.capacity_nominal, self.rated_cfm_per_ton, 'm^3/s') # in m^3/s # Fan power parameters self.fan_power_rated = kwargs['Rated Auxiliary Power (W)'] - self.fan_power_per_flow_rate = self.fan_power_rated / self.rated_flow_rate - self.fan_power_list = [self.fan_power_per_flow_rate * rate for rate in self.flow_rate_list] # in W + self.is_ducted = kwargs.get('Ducted') is not None + self.fan_power_per_flow_rate = self.fan_power_rated / self.design_airflow # in W per m^3/s + self.fan_power_max = self.fan_power_per_flow_rate * self.flow_rate_list[-1] + self.fan_power_list = [ + utils_equipment.calculate_fan_power( + self.fan_power_max, + rate / self.design_airflow, + self.fan_motor_type, + self.is_ducted, + ) + for rate in self.flow_rate_list + ] # in W self.fan_power = 0 # in W - self.fan_power_max = max(self.fan_power_list) self.fan_power_ratio = self.fan_power_max / (self.capacity_max * self.eir_max) # For ideal capacity equipment initial_setpoint = kwargs['initial_schedule'][f'{self.end_use} Setpoint (C)'] self.coil_input_db = initial_setpoint # Dry bulb temperature after increase from fan power @@ -129,7 +129,6 @@ def __init__(self, envelope_model=None, use_ideal_capacity=None, **kwargs): f' ({len(speed_list) - 1})') # Duct location and distribution system efficiency (DSE) - self.is_ducted = kwargs.get('Ducted') is not None ducts = kwargs.get('Ducts', {'DSE (-)': 1}) self.duct_dse = ducts.get('DSE (-)') # Duct distribution system efficiency self.duct_zone = self.envelope_model.zones.get(ducts.get('Zone')) @@ -704,7 +703,6 @@ def __init__(self, control_type='Time', **kwargs): self.datapoint_by_speed_clg = None self.detailed_performance_data_htg = kwargs.get('HeatingDetailedPerformance', None) self.detailed_performance_data_clg = kwargs.get('CoolingDetailedPerformance', None) - self.fan_motor_type = kwargs.get('Fan Motor Type') # 'PSC' or 'BPM' # startup capacity degradation parameters self.startup_cap_mult = 1.0 # multiplier, unitless @@ -734,7 +732,7 @@ def __init__(self, control_type='Time', **kwargs): kwargs['Capacity (W)'] = [speed_data['capacity'] for speed_data in rated_dp.values()] kwargs['EIR (-)'] = [1 / speed_data['COP'] for speed_data in rated_dp.values()] kwargs['SHR (-)'] = [0.708] * len(kwargs['Capacity (W)']) - else: + elif not kwargs.get('Disable HVAC Biquadratics', False): if self.name != 'Room AC': raise OCHREException('Detailed performance data is required for dynamic HVAC equipment other than Room AC.') else: @@ -749,25 +747,57 @@ def __init__(self, control_type='Time', **kwargs): print(self.capacity_list) if self.detailed_performance_data_htg: - self.datapoint_by_speed_htg = utils_equipment.process_detailed_performance_data(self.detailed_performance_data_htg, 'Heating', self.capacity_nominal, self.rated_cfm_per_ton, self.capacity_list, self.fan_power_per_flow_rate, self.fan_motor_type, self.is_ducted) + self.datapoint_by_speed_htg = utils_equipment.process_detailed_performance_data(self.detailed_performance_data_htg, 'Heating', self.capacity_nominal, self.rated_flow_rate, self.capacity_list, self.fan_power_per_flow_rate, self.fan_motor_type, self.is_ducted) for index, eir_t_curve in enumerate(self.eir_t): if index == 0: continue # skip off speed - eir_t_curve.set(datapoints=self.datapoint_by_speed_htg[self.SPEED_INDEX_MAP[index]]) + eir_t_curve.set( + datapoints=self.datapoint_by_speed_htg[self.SPEED_INDEX_MAP[index]], + rated_value=self.eir_list[index], + output_key='gross_EIR', + hvac_mode='Heating', + ) for index, cap_t_curve in enumerate(self.cap_t): if index == 0: continue # skip off speed - cap_t_curve.set(datapoints=self.datapoint_by_speed_htg[self.SPEED_INDEX_MAP[index]]) + cap_t_curve.set( + datapoints=self.datapoint_by_speed_htg[self.SPEED_INDEX_MAP[index]], + rated_value=self.capacity_list[index], + output_key='gross_capacity', + hvac_mode='Heating', + ) + for cap_ff in self.cap_ff: + cap_ff.set(coefficients=np.array([0.694045465, 0.474207981, -0.168253446], dtype=float)) + for eir_ff in self.eir_ff: + eir_ff.set(coefficients=np.array([2.185418751, -1.942827919, 0.757409168], dtype=float)) + for eir_plr in self.eir_plr: + eir_plr.set(coefficients=np.array([(1.0 - self.c_d), self.c_d, 0.0], dtype=float)) if self.detailed_performance_data_clg: - self.datapoint_by_speed_clg = utils_equipment.process_detailed_performance_data(self.detailed_performance_data_clg, 'Cooling', self.capacity_nominal, self.rated_cfm_per_ton, self.capacity_list, self.fan_power_per_flow_rate, self.fan_motor_type, self.is_ducted) + self.datapoint_by_speed_clg = utils_equipment.process_detailed_performance_data(self.detailed_performance_data_clg, 'Cooling', self.capacity_nominal, self.rated_flow_rate, self.capacity_list, self.fan_power_per_flow_rate, self.fan_motor_type, self.is_ducted) for index, eir_t_curve in enumerate(self.eir_t): if index == 0: continue # skip off speed - eir_t_curve.set(datapoints=self.datapoint_by_speed_clg[self.SPEED_INDEX_MAP[index]]) + eir_t_curve.set( + datapoints=self.datapoint_by_speed_clg[self.SPEED_INDEX_MAP[index]], + rated_value=self.eir_list[index], + output_key='gross_EIR', + hvac_mode='Cooling', + ) for index, cap_t_curve in enumerate(self.cap_t): if index == 0: continue # skip off speed - cap_t_curve.set(datapoints=self.datapoint_by_speed_clg[self.SPEED_INDEX_MAP[index]]) + cap_t_curve.set( + datapoints=self.datapoint_by_speed_clg[self.SPEED_INDEX_MAP[index]], + rated_value=self.capacity_list[index], + output_key='gross_capacity', + hvac_mode='Cooling', + ) + for cap_ff in self.cap_ff: + cap_ff.set(coefficients=np.array([0.718664047, 0.41797409, -0.136638137], dtype=float)) + for eir_ff in self.eir_ff: + eir_ff.set(coefficients=np.array([1.143487507, -0.13943972, -0.004047787], dtype=float)) + for eir_plr in self.eir_plr: + eir_plr.set(coefficients=np.array([(1.0 - self.c_d), self.c_d, 0.0], dtype=float)) # Check EIR and print warning if too low if self.eir_max > 0.5: @@ -858,51 +888,6 @@ def run_thermostat_control(self, setpoint=None): return self.run_two_speed_control() else: raise OCHREException('Incompatible number of speeds for dynamic equipment:', self.n_speeds) - - def calculate_performance_curves(self, param, speed_idx, flow_fraction=1, part_load_ratio=1, biquadratic=True): - # runs biquadratic equation or detailed performance interpolation for EIR or capacity given the speed index - # param is 'cap' or 'eir' - - # get rated value based on speed - if param == 'cap': - rated = self.capacity_list[speed_idx] - elif param == 'eir': - rated = self.eir_list[speed_idx] - else: - raise OCHREException('Unknown biquadratic parameter:', param) - - if speed_idx == 0 or self.biquad_params is None: - return rated - - - # use coil input wet bulb for cooling, dry bulb for heating; ambient dry bulb for both - t_in = self.coil_input_db if self.is_heater else self.coil_input_wb - t_ext_db = self.current_schedule['Ambient Dry Bulb (C)'] - - # clip temperatures, flow fraction, part load ratio to stay within bounds - t_in = min(max(t_in, params['min_Twb']), params['max_Twb']) - t_ext_db = min(max(t_ext_db, params['min_Tdb']), params['max_Tdb']) - flow_fraction = min(max(flow_fraction, params['min_ff']), params['max_ff']) - - if biquadratic: - # get biquadratic parameters for current speed - params = self.biquad_params[speed_idx] - - # create vectors based on temperature, flow fraction, and plr - t_list = np.array([1, t_in, t_in ** 2, t_ext_db, t_ext_db ** 2, t_in * t_ext_db], dtype=float) - t_ratio = np.dot(t_list, params[param + '_t']) - - ff_list = np.array([1, flow_fraction, flow_fraction ** 2], dtype=float) - ff_ratio = np.dot(ff_list, params[param + '_ff']) - - plf_list = np.array([1, part_load_ratio, part_load_ratio ** 2], dtype=float) - plf_ratio = np.dot(plf_list, params[param + '_plr']) - plf_ratio = min(max(plf_ratio, params['min_plf']), params['max_plf']) - else: - if self.is_heater and self.detailed_performance_data_htg: - rated_dp = self.detailed_performance_data_htg[utils_equipment.AIR_SOURCE_HEAT_RATED_ODB] - - return rated * t_ratio * ff_ratio / plf_ratio def calculate_biquadratic_param(self, param, speed_idx, flow_fraction=1, part_load_ratio=1): # runs biquadratic equation for EIR or capacity given the speed index @@ -927,27 +912,20 @@ def calculate_biquadratic_param(self, param, speed_idx, flow_fraction=1, part_lo else: raise OCHREException('Unknown biquadratic parameter:', param) + if speed_idx == 0: + return rated + # use coil input wet bulb for cooling, dry bulb for heating; ambient dry bulb for both t_in = self.coil_input_db if self.is_heater else self.coil_input_wb t_ext_db = self.current_schedule['Ambient Dry Bulb (C)'] - # clip temperatures, flow fraction, part load ratio to stay within bounds - t_in = min(max(t_in, curve_t.min_x1), curve_t.max_x1) - t_ext_db = min(max(t_ext_db, curve_t.min_x2), curve_t.max_x2) - flow_fraction = min(max(flow_fraction, curve_ff.min_x1), curve_ff.max_x1) - # create vectors based on temperature, flow fraction, and plr - t_list = np.array([1, t_in, t_in ** 2, t_ext_db, t_ext_db ** 2, t_in * t_ext_db], dtype=float) - t_ratio = np.dot(t_list, curve_t.coefficients) - - ff_list = np.array([1, flow_fraction, flow_fraction ** 2], dtype=float) - ff_ratio = np.dot(ff_list, curve_ff.coefficients) + t_ratio = curve_t.evaluate(t_in, t_ext_db) + ff_ratio = curve_ff.evaluate(flow_fraction) + plf_ratio = 1.0 if param == 'eir': - part_load_ratio = min(max(part_load_ratio, curve_plr.min_x1), curve_plr.max_x1) - plf_list = np.array([1, part_load_ratio, part_load_ratio ** 2], dtype=float) - plf_ratio = np.dot(plf_list, curve_plr.coefficients) - plf_ratio = min(max(plf_ratio, curve_plr.min_y), curve_plr.max_y) + plf_ratio = curve_plr.evaluate(part_load_ratio) return rated * t_ratio * ff_ratio / plf_ratio @@ -981,7 +959,7 @@ def update_capacity(self): # determine capacity for each speed, check that capacity_ratio increases with speed capacities = [self.calculate_biquadratic_param(param='cap', speed_idx=speed) for speed in range(self.n_speeds + 1)] - assert (np.diff(capacities) > 0).all() + assert (np.diff(capacities) >= 0).all() # determine ideal capacity capacity = super().update_capacity() @@ -1046,8 +1024,8 @@ def __init__(self, **kwargs): # Update PLF parameters for low efficiency equipment seer = convert(1 / self.eir, 'W', 'Btu/hour') - if self.n_speeds == 1 and seer < 13 and self.biquad_params is not None: - self.biquad_params[1]['eir_plf'] = np.array([0.8, 0.2, 0]) + if self.n_speeds == 1 and seer < 13 and self.eir_plr is not None: + self.eir_plr[1].set(coefficients=np.array([0.8, 0.2, 0], dtype=float), min_y=0.7, max_y=1.0) def calculate_power_and_heat(self): super().calculate_power_and_heat() @@ -1104,8 +1082,8 @@ def __init__(self, **kwargs): # Update PLF parameters for low efficiency equipment hspf = convert(1 / self.eir, 'W', 'Btu/hour') - if self.biquad_params is not None and self.n_speeds == 1 and hspf >= 7: - self.biquad_params[1]['eir_plf'] = np.array([0.89, 0.11, 0]) + if self.eir_plr is not None and self.n_speeds == 1 and hspf >= 7: + self.eir_plr[1].set(coefficients=np.array([0.89, 0.11, 0], dtype=float), min_y=0.7, max_y=1.0) def update_capacity(self): # Update capacity if defrost is required @@ -1478,6 +1456,7 @@ def calculate_power_and_heat(self): if self.pan_heater_on: self.electric_kw += self.pan_heater_kw * self.space_fraction + class PerformanceCurve: REQUIRED_COEFFS = { "quadratic": 3, @@ -1489,6 +1468,20 @@ def __init__(self, curve_type, variable_type): self.variable_type = variable_type self.coefficients = None self.datapoints = None + self.rated_value = None + self.output_key = None + self.hvac_mode = None + self.use_outdoor_base_interpolation = False + self.base_outdoor_axis = None + self.base_net_capacity = None + self.base_net_input_power = None + self.base_fan_power = None + self.interp_method_x1 = 'linear' + self.interp_method_x2 = 'linear' + self.extrapolation_x1 = 'constant' # options: constant, linear + self.extrapolation_x2 = 'constant' # options: constant, linear + self.allow_extrapolation_x1 = True + self.allow_extrapolation_x2 = True if variable_type == 'temperature': self.min_x1 = -100.0 self.max_x1 = 100.0 @@ -1497,9 +1490,96 @@ def __init__(self, curve_type, variable_type): elif variable_type == 'fraction': self.min_x1 = 0.0 self.max_x1 = 1.0 + else: + raise OCHREException(f"Unsupported Performance Curve variable_type: {variable_type}") self.min_y = None self.max_y = None + def _fit_outdoor_base_model(self): + if self.output_key not in ['gross_capacity', 'gross_EIR']: + raise OCHREException(f'Outdoor-base interpolation does not support output_key: {self.output_key}') + + required_fields = ['outdoor_temperature', 'net_capacity', 'net_COP', 'gross_capacity'] + missing = [ + idx for idx, dp in enumerate(self.datapoints) + if any(field not in dp for field in required_fields) + ] + if missing: + raise OCHREException( + f'Datapoints missing required net/gross fields {required_fields}. First missing index: {missing[0]}' + ) + + # Internal checking + if any('net_input_power' not in dp for dp in self.datapoints): + raise OCHREException('Datapoint missing net_input_power. Ensure preprocessing adds net_input_power.') + + if self.hvac_mode is None: + if any('indoor_wetbulb' in dp for dp in self.datapoints): + self.hvac_mode = 'Cooling' + else: + self.hvac_mode = 'Heating' + + rated_indoor = (utils_equipment.AIR_SOURCE_COOL_RATED_IWB + if self.hvac_mode == 'Cooling' + else utils_equipment.AIR_SOURCE_HEAT_RATED_IDB) + indoor_key = 'indoor_wetbulb' if self.hvac_mode == 'Cooling' else 'indoor_temperature' + + rated_rows = [ + dp for dp in self.datapoints + if indoor_key in dp and abs(float(dp[indoor_key]) - rated_indoor) < 1e-6 and 'outdoor_temperature' in dp + ] + if len(rated_rows) < 2: + raise OCHREException('Need at least 2 rated-indoor rows to fit outdoor-base interpolation model') + + by_outdoor = {} + for dp in rated_rows: + by_outdoor[float(dp['outdoor_temperature'])] = dp + + outdoor_axis = np.array(sorted(by_outdoor.keys()), dtype=float) + if outdoor_axis.size < 2: + raise OCHREException('Need at least 2 unique outdoor temperatures to fit outdoor-base interpolation model') + + net_capacity = [] + net_input_power = [] + fan_power_values = [] + + for t_out in outdoor_axis: + dp = by_outdoor[t_out] + net_cap = float(dp['net_capacity']) + net_power = float(dp['net_input_power']) + gross_cap = float(dp['gross_capacity']) + + if gross_cap is not None: + if self.hvac_mode == 'Cooling': + fan_power_values.append(gross_cap - net_cap) + else: + fan_power_values.append(net_cap - gross_cap) + + net_capacity.append(net_cap) + net_input_power.append(net_power) + + if not fan_power_values: + raise OCHREException('Unable to infer fan power from datapoints for outdoor-base interpolation model') + + self.base_outdoor_axis = outdoor_axis + self.base_net_capacity = np.array(net_capacity, dtype=float) + self.base_net_input_power = np.array(net_input_power, dtype=float) + self.base_fan_power = float(np.median(np.array(fan_power_values, dtype=float))) + + self.coefficients = None + + def _fit_biquadratic_from_datapoints(self): + # Step 0: Ensure we only process table lookup data for biquadratic temperature curves. + if self.curve_type != 'biquadratic' or not self.datapoints: + return + + # Step 1: Set/validate the rated normalization value. + if self.rated_value is None or self.rated_value <= 0: + raise OCHREException('rated_value must be positive when setting datapoints for biquadratic curves') + + self._fit_outdoor_base_model() + return + def set(self, **kwargs): for key, value in kwargs.items(): if not hasattr(self, key): @@ -1511,15 +1591,126 @@ def set(self, **kwargs): raise OCHREException( f"{self.curve_type} curve requires {required} coefficients" ) + if key in ["interp_method_x1", "interp_method_x2"] and value != 'linear': + raise OCHREException(f"Only linear interpolation is currently supported for {key}") + if key in ["extrapolation_x1", "extrapolation_x2"] and value not in ['constant', 'linear']: + raise OCHREException(f"{key} must be either 'constant' or 'linear'") setattr(self, key, value) + + # Auto-detect outdoor-base interpolation for biquadratic curves with datapoints + if self.datapoints is not None and self.curve_type == 'biquadratic': + self.use_outdoor_base_interpolation = True + + if self.datapoints is not None: + self._fit_biquadratic_from_datapoints() + + def _interpolate_1d(self, x_axis, y_axis, x_value, extrapolation='constant', allow_extrapolation=True): + # x_axis is assumed sorted ascending + if len(x_axis) == 0: + raise OCHREException('Cannot interpolate with empty axis') + if len(x_axis) == 1: + return float(y_axis[0]) + + x_min = x_axis[0] + x_max = x_axis[-1] + + if x_min <= x_value <= x_max: + return float(np.interp(x_value, x_axis, y_axis)) + + if not allow_extrapolation: + raise OCHREException( + f'Extrapolation not allowed for value {x_value}. Valid range is [{x_min}, {x_max}]' + ) + + if extrapolation == 'constant': + return float(y_axis[0] if x_value < x_min else y_axis[-1]) + elif extrapolation == 'linear': + if x_value < x_min: + slope = (y_axis[1] - y_axis[0]) / (x_axis[1] - x_axis[0]) + return float(y_axis[0] + slope * (x_value - x_axis[0])) + else: + slope = (y_axis[-1] - y_axis[-2]) / (x_axis[-1] - x_axis[-2]) + return float(y_axis[-1] + slope * (x_value - x_axis[-1])) + else: + raise OCHREException(f'Unknown extrapolation mode: {extrapolation}') + + def _clip(self, value, low, high): + if low is not None: + value = max(value, low) + if high is not None: + value = min(value, high) + return value - def evaluate(self, x): + def evaluate(self, x1, x2=None): + x1 = self._clip(x1, self.min_x1, self.max_x1) + if self.curve_type == 'quadratic': + if self.coefficients is None: + raise OCHREException('quadratic curve coefficients are not set') a, b, c = self.coefficients - return a * x**2 + b * x + c + y = a * x1**2 + b * x1 + c elif self.curve_type == 'cubic': + if self.coefficients is None: + raise OCHREException('cubic curve coefficients are not set') a, b, c, d = self.coefficients - return a * x**3 + b * x**2 + c * x + d + y = a * x1**3 + b * x1**2 + c * x1 + d + elif self.curve_type == 'biquadratic': + if x2 is None: + raise OCHREException('biquadratic evaluation requires x1 and x2') + x2 = self._clip(x2, self.min_x2, self.max_x2) + + if self.base_outdoor_axis is not None: + net_capacity = self._interpolate_1d( + self.base_outdoor_axis, + self.base_net_capacity, + x2, + extrapolation=self.extrapolation_x2, + allow_extrapolation=self.allow_extrapolation_x2, + ) + net_input_power = self._interpolate_1d( + self.base_outdoor_axis, + self.base_net_input_power, + x2, + extrapolation=self.extrapolation_x2, + allow_extrapolation=self.allow_extrapolation_x2, + ) + + if self.hvac_mode == 'Cooling': + gross_capacity = net_capacity + self.base_fan_power + else: + gross_capacity = net_capacity - self.base_fan_power + gross_input_power = net_input_power - self.base_fan_power + + if gross_capacity <= 0 or gross_input_power <= 0: + raise OCHREException( + f'Invalid gross values during curve evaluation: capacity={gross_capacity}, power={gross_input_power}' + ) + + gross_eir = gross_input_power / gross_capacity + cap_corr, eir_corr = utils_equipment.get_ft_cap_eir_correction_factors(self.hvac_mode, x1, x2) + + if self.output_key == 'gross_capacity': + y = (gross_capacity * cap_corr) / self.rated_value + elif self.output_key == 'gross_EIR': + y = (gross_eir * eir_corr) / self.rated_value + else: + raise OCHREException(f'Unsupported output_key for outdoor base interpolation: {self.output_key}') + elif self.datapoints is not None: + raise OCHREException( + 'Datapoint-based biquadratic curves must use outdoor-base interpolation with net fields; ' + 'regular-grid fallback is disabled.' + ) + else: + if self.coefficients is None: + raise OCHREException('biquadratic curve requires coefficients or datapoints') + a, b, c, d, e, f = self.coefficients + y = a + b * x1 + c * x1**2 + d * x2 + e * x2**2 + f * x1 * x2 else: - raise OCHREException('Unknown curve type: {}'.format(self.curve_type)) \ No newline at end of file + raise OCHREException('Unknown curve type: {}'.format(self.curve_type)) + + if self.min_y is not None: + y = max(y, self.min_y) + if self.max_y is not None: + y = min(y, self.max_y) + return y \ No newline at end of file diff --git a/ochre/utils/equipment.py b/ochre/utils/equipment.py index babc2e55..afb6ed01 100644 --- a/ochre/utils/equipment.py +++ b/ochre/utils/equipment.py @@ -664,7 +664,7 @@ def calculate_mass_flow_rate(DBin, Win, P, flow): mfr = flow * rho_in return mfr -def process_detailed_performance_data(detailed_performance_data, mode, nominal_capacity, rated_cfm_per_ton, capacity_list, fan_power_per_flow_rate, fan_motor_type, is_ducted): +def process_detailed_performance_data(detailed_performance_data, mode, nominal_capacity, rated_airflow, capacity_list, fan_power_per_flow_rate, fan_motor_type, is_ducted): datapoints_by_speed = {} for outtemp, speed_data in detailed_performance_data.items(): for speed_description, datapoints in speed_data.items(): @@ -677,7 +677,7 @@ def process_detailed_performance_data(detailed_performance_data, mode, nominal_c dp['net_capacity'] = datapoints['capacity'] dp['net_COP'] = datapoints['COP'] datapoints_by_speed[speed_description].append(dp) - datapoints_by_speed = convert_datapoint_net_to_gross(datapoints_by_speed, mode, nominal_capacity, rated_cfm_per_ton, capacity_list, fan_power_per_flow_rate, fan_motor_type, is_ducted) + datapoints_by_speed = convert_datapoint_net_to_gross(datapoints_by_speed, mode, nominal_capacity, rated_airflow, capacity_list, fan_power_per_flow_rate, fan_motor_type, is_ducted) #extrapolate_datapoints(datapoints_by_speed mode, hp_min_temp, weather_temp) datapoints_by_speed = correct_ft_cap_eir(datapoints_by_speed, mode) print(datapoints_by_speed) @@ -690,19 +690,54 @@ def calculate_biquadratic(x, y, c): z = c[0] + c[1] * x + c[2] * x**2 + c[3] * y + c[4] * y**2 + c[5] * y * x return z -def correct_ft_cap_eir(datapoints_by_speed, mode): +def get_ft_cap_eir_correction_factors(mode, indoor_temp_c, outdoor_temp_c): if mode == "Cooling": rated_t_i = AIR_SOURCE_COOL_RATED_IWB - indoor_t = [convert(57.0, 'degF', 'degC'), rated_t_i, convert(72.0, 'degF', 'degC')] # Cutler curve used for introducing indoor temperature dependence to capacity and EIR. cap_ft_spec_ss = [3.717717741, -0.09918866, 0.000964488, 0.005887776, -0.000012808, -0.000132822] eir_ft_spec_ss = [-3.400341169, 0.135184783, -0.001037932, -0.007852322, 0.000183438, -0.000142548] + # Cooling variations held constant for Todb less than 75°F + curve_t_o = max(outdoor_temp_c, convert(75.0, 'degF', 'degC')) else: rated_t_i = AIR_SOURCE_HEAT_RATED_IDB - indoor_t = [convert(60.0, 'degF', 'degC'), rated_t_i, convert(80.0, 'degF', 'degC')] # Cutler curve used for introducing indoor temperature dependence to capacity and EIR. cap_ft_spec_ss = [0.568706266, -0.000747282, -0.0000103432, 0.00945408, 0.000050812, -0.00000677828] eir_ft_spec_ss = [0.722917608, 0.003520184, 0.000143097, -0.005760341, 0.000141736, -0.000216676] + curve_t_o = outdoor_temp_c + + cap_ft_curve_output = calculate_biquadratic( + convert(indoor_temp_c, 'degC', 'degF'), + convert(curve_t_o, 'degC', 'degF'), + cap_ft_spec_ss, + ) + cap_ft_curve_output_rated = calculate_biquadratic( + convert(rated_t_i, 'degC', 'degF'), + convert(curve_t_o, 'degC', 'degF'), + cap_ft_spec_ss, + ) + cap_correction_factor = cap_ft_curve_output / cap_ft_curve_output_rated + + eir_ft_curve_output = calculate_biquadratic( + convert(indoor_temp_c, 'degC', 'degF'), + convert(curve_t_o, 'degC', 'degF'), + eir_ft_spec_ss, + ) + eir_ft_curve_output_rated = calculate_biquadratic( + convert(rated_t_i, 'degC', 'degF'), + convert(curve_t_o, 'degC', 'degF'), + eir_ft_spec_ss, + ) + eir_correction_factor = eir_ft_curve_output / eir_ft_curve_output_rated + + return cap_correction_factor, eir_correction_factor + +def correct_ft_cap_eir(datapoints_by_speed, mode): + if mode == "Cooling": + rated_t_i = AIR_SOURCE_COOL_RATED_IWB + indoor_t = [convert(57.0, 'degF', 'degC'), rated_t_i, convert(72.0, 'degF', 'degC')] + else: + rated_t_i = AIR_SOURCE_HEAT_RATED_IDB + indoor_t = [convert(60.0, 'degF', 'degC'), rated_t_i, convert(80.0, 'degF', 'degC')] for speed_description, datapoints in datapoints_by_speed.items(): for dp in datapoints: @@ -722,20 +757,15 @@ def correct_ft_cap_eir(datapoints_by_speed, mode): dp_new = dp.copy() if mode == "Cooling": dp_new['indoor_wetbulb'] = t_i - # Cooling variations shall be held constant for Tiwb less than 57°F and greater than 72°F, and for Todb less than 75°F - curve_t_o = max(dp_new['outdoor_temperature'], convert(75.0, 'degF', 'degC')) else: dp_new['indoor_temperature'] = t_i - curve_t_o = dp_new['outdoor_temperature'] - cap_ft_curve_output = calculate_biquadratic(convert(t_i, 'degC', 'degF'), convert(curve_t_o, 'degC', 'degF'), cap_ft_spec_ss) - cap_ft_curve_output_rated = calculate_biquadratic(convert(rated_t_i, 'degC', 'degF'), convert(curve_t_o, 'degC', 'degF'), cap_ft_spec_ss) - cap_correction_factor = cap_ft_curve_output / cap_ft_curve_output_rated + cap_correction_factor, eir_correction_factor = get_ft_cap_eir_correction_factors( + mode, + t_i, + dp_new['outdoor_temperature'], + ) # corrected capacity hash, with two temperature independent variables dp_new['gross_capacity'] *= cap_correction_factor - # EIR FT curve output - eir_ft_curve_output = calculate_biquadratic(convert(t_i, 'degC', 'degF'), convert(curve_t_o, 'degC', 'degF'), eir_ft_spec_ss) - eir_ft_curve_output_rated = calculate_biquadratic(convert(rated_t_i, 'degC', 'degF'), convert(curve_t_o, 'degC', 'degF'), eir_ft_spec_ss) - eir_correction_factor = eir_ft_curve_output / eir_ft_curve_output_rated dp_new['gross_COP'] /= eir_correction_factor data_tmp.append(dp_new) array_tmp.append(data_tmp) @@ -743,28 +773,25 @@ def correct_ft_cap_eir(datapoints_by_speed, mode): datapoints.extend(new_data) return datapoints_by_speed -def convert_datapoint_net_to_gross(datapoints_by_speed, mode, nominal_capacity, rated_cfm_per_ton, capacity_list, fan_power_per_flow_rate, fan_motor_type, is_ducted): +def convert_datapoint_net_to_gross(datapoints_by_speed, mode, nominal_capacity, rated_airflow, capacity_list, fan_power_per_flow_rate, fan_motor_type, is_ducted): speed_index = { HPXML_SPEED_DESCRIPTION_MINIMUM: 1, HPXML_SPEED_DESCRIPTION_NOMINAL: 2, HPXML_SPEED_DESCRIPTION_MAXIMUM: 3 } # add 1 to speed index to include off speed in capacity list - # Calculate rated cfm based on cooling per AHRI - rated_cfm = calc_rated_airflow(nominal_capacity, rated_cfm_per_ton, 'cubic_feet/min') print(f'Nominal capacity: {nominal_capacity} W, {convert(nominal_capacity, "W", "refrigeration_ton")} tons') - print(f'Rated CFM based on nominal capacity and system type: {rated_cfm} cfm') - if rated_cfm < 3: # Resort to heating if we get a HP w/ only heating - raise OCHREException(f'Rated CFM is too low ({rated_cfm} cfm). Check if the nominal capacity and system type are correct.') + print(f'Rated airflow based on nominal capacity and system type: {convert(rated_airflow, "m^3/s", "cubic_feet/min")} cfm') + if convert(rated_airflow, 'm^3/s', 'cubic_feet/min') < 3: # Resort to heating if we get a HP w/ only heating + raise OCHREException(f'Rated CFM is too low ({convert(rated_airflow, "m^3/s", "cubic_feet/min")} cfm). Check if the nominal capacity and system type are correct.') # data structure: datapoints_by_speed[speed_description][outtemp]['net_capacity'] = 1000 for speed_description, datapoints in datapoints_by_speed.items(): for dp in datapoints: - fan_cfm = rated_cfm * (capacity_list[speed_index[speed_description]] / nominal_capacity) - fan_ratio = fan_cfm / rated_cfm # equal to capacity ratio in this case, OS-HPXML could be different since the rated_cfm - watts_per_cfm = convert(fan_power_per_flow_rate, 'W/(m^3/s)', 'W/(cubic_feet/min)') - fan_power = calculate_fan_power(watts_per_cfm * rated_cfm, fan_ratio, fan_motor_type, is_ducted) + fan_airflow = rated_airflow * (capacity_list[speed_index[speed_description]] / nominal_capacity) + fan_ratio = fan_airflow / rated_airflow # equal to capacity ratio in this case, OS-HPXML could be different since the rated_cfm + fan_power = calculate_fan_power(fan_power_per_flow_rate * rated_airflow, fan_ratio, fan_motor_type, is_ducted) dp['gross_capacity'], dp['gross_COP'] = convert_net_to_gross_capacity_cop(mode, dp['net_capacity'], fan_power, dp['net_COP']) - dp['input_power'] = dp['net_capacity'] / dp['net_COP'] # W + dp['net_input_power'] = dp['net_capacity'] / dp['net_COP'] # W dp['gross_input_power'] = dp['gross_capacity'] / dp['gross_COP'] # W return datapoints_by_speed @@ -804,6 +831,12 @@ def get_rated_cfm_per_ton(system_type): else: return 400 +def get_design_cfm_per_ton(system_type): + if system_type == 'Furnace' or system_type == 'Boiler': + return 240 + else: + return 360 + def calculate_shr(DBin, Win, P, Q, flow, Ao): """ Description: diff --git a/ochre/utils/hpxml.py b/ochre/utils/hpxml.py index 1faf4ec7..1821cb74 100644 --- a/ochre/utils/hpxml.py +++ b/ochre/utils/hpxml.py @@ -1484,6 +1484,9 @@ def sort_detailed_performance(performance): qm17full = 0.69 # NEEP database capacity17f = qm17full * capacity + # Get design air flow rates + design_airflow_cfm = hvac_ext.get(f"{hvac_type}AirflowCFM", None) + # Get auxiliary power (fans, pumps, etc.) air flow rate if name == "Boiler": # Note: ResStock assumes 2080 hours/year, see hvac.rb line 1754 (get_default_boiler_eae) @@ -1494,13 +1497,15 @@ def sort_detailed_performance(performance): elif "FanPowerWattsPerCFM" in hvac_ext: # Note: air flow rate is only used for non-dymanic HVAC models with fans, e.g., furnaces # airflow_cfm = hvac_ext.get(f'{hvac_type}AirflowCFM', 0) - cfm_per_ton = utils_equipment.get_rated_cfm_per_ton(name) + if design_airflow_cfm is None: + design_airflow_cfm = utils_equipment.get_design_cfm_per_ton(name) * convert(capacity, "W", "refrigeration_ton") power_per_cfm = hvac_ext.get("FanPowerWattsPerCFM", 0) aux_power = ( - power_per_cfm * cfm_per_ton * convert(capacity, "W", "refrigeration_ton") + power_per_cfm * design_airflow_cfm ) else: aux_power = hvac_ext.get("FanPowerWatts", 0) + if "FanMotorType" in hvac_ext: fan_motor_type = hvac_ext.get("FanMotorType") elif name in detailed_performance_sys_types: @@ -1521,6 +1526,7 @@ def sort_detailed_performance(performance): "Number of Speeds (-)": number_of_speeds, "Rated Auxiliary Power (W)": aux_power, "Fan Motor Type": fan_motor_type, + "Design Airflow (CFM)": design_airflow_cfm, } # Add startup capacity degradation factor for AC and heat pumps From 26b4afb9d779090478f0887c147bb2fc9bac183c Mon Sep 17 00:00:00 2001 From: Yueyue Zhou Date: Mon, 23 Mar 2026 15:16:57 -0600 Subject: [PATCH 11/14] fix curve calc, remove debugging statements --- ochre/Dwelling.py | 1 - ochre/Equipment/HVAC.py | 5 ++--- ochre/utils/equipment.py | 3 --- 3 files changed, 2 insertions(+), 7 deletions(-) diff --git a/ochre/Dwelling.py b/ochre/Dwelling.py index 337f4f5d..dfda961e 100644 --- a/ochre/Dwelling.py +++ b/ochre/Dwelling.py @@ -138,7 +138,6 @@ def __init__( # Create all equipment self.equipment = {} for equipment_name, equipment_args in equipment_dict.items(): - print(equipment_name) cls = equipment_args.pop("equipment_class", EQUIPMENT_BY_NAME.get(equipment_name)) equipment_args = {**sim_args, **equipment_args} eq = cls(name=equipment_name, **equipment_args) diff --git a/ochre/Equipment/HVAC.py b/ochre/Equipment/HVAC.py index e3125a5d..e28b37aa 100644 --- a/ochre/Equipment/HVAC.py +++ b/ochre/Equipment/HVAC.py @@ -745,7 +745,6 @@ def __init__(self, control_type='Time', **kwargs): super().__init__(**kwargs) - print(self.capacity_list) if self.detailed_performance_data_htg: self.datapoint_by_speed_htg = utils_equipment.process_detailed_performance_data(self.detailed_performance_data_htg, 'Heating', self.capacity_nominal, self.rated_flow_rate, self.capacity_list, self.fan_power_per_flow_rate, self.fan_motor_type, self.is_ducted) for index, eir_t_curve in enumerate(self.eir_t): @@ -1649,12 +1648,12 @@ def evaluate(self, x1, x2=None): if self.coefficients is None: raise OCHREException('quadratic curve coefficients are not set') a, b, c = self.coefficients - y = a * x1**2 + b * x1 + c + y = a + b * x1 + c * x1**2 elif self.curve_type == 'cubic': if self.coefficients is None: raise OCHREException('cubic curve coefficients are not set') a, b, c, d = self.coefficients - y = a * x1**3 + b * x1**2 + c * x1 + d + y = a + b * x1 + c * x1**2 + d * x1**3 elif self.curve_type == 'biquadratic': if x2 is None: raise OCHREException('biquadratic evaluation requires x1 and x2') diff --git a/ochre/utils/equipment.py b/ochre/utils/equipment.py index afb6ed01..1074089f 100644 --- a/ochre/utils/equipment.py +++ b/ochre/utils/equipment.py @@ -680,7 +680,6 @@ def process_detailed_performance_data(detailed_performance_data, mode, nominal_c datapoints_by_speed = convert_datapoint_net_to_gross(datapoints_by_speed, mode, nominal_capacity, rated_airflow, capacity_list, fan_power_per_flow_rate, fan_motor_type, is_ducted) #extrapolate_datapoints(datapoints_by_speed mode, hp_min_temp, weather_temp) datapoints_by_speed = correct_ft_cap_eir(datapoints_by_speed, mode) - print(datapoints_by_speed) return datapoints_by_speed def calculate_biquadratic(x, y, c): @@ -779,8 +778,6 @@ def convert_datapoint_net_to_gross(datapoints_by_speed, mode, nominal_capacity, HPXML_SPEED_DESCRIPTION_NOMINAL: 2, HPXML_SPEED_DESCRIPTION_MAXIMUM: 3 } # add 1 to speed index to include off speed in capacity list - print(f'Nominal capacity: {nominal_capacity} W, {convert(nominal_capacity, "W", "refrigeration_ton")} tons') - print(f'Rated airflow based on nominal capacity and system type: {convert(rated_airflow, "m^3/s", "cubic_feet/min")} cfm') if convert(rated_airflow, 'm^3/s', 'cubic_feet/min') < 3: # Resort to heating if we get a HP w/ only heating raise OCHREException(f'Rated CFM is too low ({convert(rated_airflow, "m^3/s", "cubic_feet/min")} cfm). Check if the nominal capacity and system type are correct.') From 5563d5bafb31a793675a8ff1938b3b465308cf55 Mon Sep 17 00:00:00 2001 From: Yueyue Zhou Date: Mon, 23 Mar 2026 15:25:43 -0600 Subject: [PATCH 12/14] run ruff on two files --- ochre/Equipment/HVAC.py | 4 +--- ochre/utils/equipment.py | 1 - 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/ochre/Equipment/HVAC.py b/ochre/Equipment/HVAC.py index e28b37aa..721379c1 100644 --- a/ochre/Equipment/HVAC.py +++ b/ochre/Equipment/HVAC.py @@ -1,10 +1,8 @@ import datetime as dt import numpy as np import psychrolib -import re -import pandas as pd -from ochre.utils import OCHREException, convert, load_csv +from ochre.utils import OCHREException, convert from ochre.utils.units import kwh_to_therms import ochre.utils.equipment as utils_equipment from ochre.Equipment import Equipment diff --git a/ochre/utils/equipment.py b/ochre/utils/equipment.py index 1074089f..aff982ff 100644 --- a/ochre/utils/equipment.py +++ b/ochre/utils/equipment.py @@ -1,5 +1,4 @@ import math -from pprint import pprint import numpy as np import psychrolib From bfbb8dded6bcf21575fced45af9896f662ff2dfb Mon Sep 17 00:00:00 2001 From: Yueyue Zhou Date: Mon, 23 Mar 2026 15:40:26 -0600 Subject: [PATCH 13/14] run ruff format on two conflicting files --- ochre/Equipment/HVAC.py | 1152 +++++++++++++++++++++++++------------- ochre/utils/equipment.py | 915 +++++++++++++++++++----------- 2 files changed, 1350 insertions(+), 717 deletions(-) diff --git a/ochre/Equipment/HVAC.py b/ochre/Equipment/HVAC.py index 721379c1..5322733b 100644 --- a/ochre/Equipment/HVAC.py +++ b/ochre/Equipment/HVAC.py @@ -8,9 +8,9 @@ from ochre.Equipment import Equipment SPEED_TYPES = { - 1: 'Single', - 2: 'Double', - 3: 'Variable', + 1: "Single", + 2: "Double", + 3: "Variable", # 10: 'Mini-split Variable', # Note: MSHP model uses 4 speeds, not 10 } @@ -26,59 +26,76 @@ class HVAC(Equipment): capacity to maintain the setpoint temperature. It does not account for heat gains from other equipment in the same time step. """ - name = 'Generic HVAC' + + name = "Generic HVAC" n_speeds = 1 def __init__(self, envelope_model=None, use_ideal_capacity=None, **kwargs): # HVAC type (Heating or Cooling) - if self.end_use == 'HVAC Heating': + if self.end_use == "HVAC Heating": self.is_heater = True self.hvac_mult = 1 - elif self.end_use == 'HVAC Cooling': + elif self.end_use == "HVAC Cooling": self.is_heater = False self.hvac_mult = -1 else: - raise OCHREException(f'HVAC type for {self.name} Equipment must be "Heating" or "Cooling".') + raise OCHREException( + f'HVAC type for {self.name} Equipment must be "Heating" or "Cooling".' + ) # Building envelope parameters - required for calculating ideal capacity # FUTURE: For now, require envelope model. In future, could use ext_model to provide all schedule values - assert self.zone_name == 'Indoor' and envelope_model is not None + assert self.zone_name == "Indoor" and envelope_model is not None self.envelope_model = envelope_model super().__init__(envelope_model=envelope_model, **kwargs) # Capacity parameters self.speed_idx = 1 # speed index, 0=Off, 1=lowest speed, max=n_speeds - if isinstance(kwargs['Capacity (W)'], list): - self.capacity_list = [0] + kwargs['Capacity (W)'] # rated capacities by speed, in W + if isinstance(kwargs["Capacity (W)"], list): + self.capacity_list = [0] + kwargs[ + "Capacity (W)" + ] # rated capacities by speed, in W else: - self.capacity_list = [0, kwargs['Capacity (W)']] + self.capacity_list = [0, kwargs["Capacity (W)"]] # Nominal and max can be the same from NEEP database assert (np.diff(self.capacity_list) >= 0).all() self.capacity = self.capacity_list[self.speed_idx] - self.capacity_ideal = self.capacity # capacity to maintain setpoint, for ideal equipment, in W + self.capacity_ideal = ( + self.capacity + ) # capacity to maintain setpoint, for ideal equipment, in W self.capacity_max = self.capacity_list[-1] # varies for dynamic equipment, in W self.nominal_index = { 1: 1, # single speed equipment only has one speed, [0, capacity] 2: 2, # for two speed equipment, nominal capacity is at high speed, [0, low_capacity, high_capacity] 3: 2, # for variable speed equipment, nominal capacity is at middle speed, [0, low_capacity, middle_capacity, high_capacity] - }[len(self.capacity_list) - 1] # subtract 1 because capacity_list includes 0 for off speed - self.capacity_nominal = self.capacity_list[self.nominal_index] # varies for dynamic equipment, in W - self.capacity_min = kwargs.get('Minimum Capacity (W)', 0) # for ideal equipment, in W - self.space_fraction = kwargs.get('Conditioned Space Fraction (-)', 1.0) + }[ + len(self.capacity_list) - 1 + ] # subtract 1 because capacity_list includes 0 for off speed + self.capacity_nominal = self.capacity_list[ + self.nominal_index + ] # varies for dynamic equipment, in W + self.capacity_min = kwargs.get( + "Minimum Capacity (W)", 0 + ) # for ideal equipment, in W + self.space_fraction = kwargs.get("Conditioned Space Fraction (-)", 1.0) self.delivered_heat = 0 # in W, total sensible heat gain, excluding duct losses # Efficiency and loss parameters - if isinstance(kwargs['EIR (-)'], list): - self.eir_list = kwargs['EIR (-)'] # Energy Input Ratios by speed, unitless + if isinstance(kwargs["EIR (-)"], list): + self.eir_list = kwargs["EIR (-)"] # Energy Input Ratios by speed, unitless else: - self.eir_list = [kwargs['EIR (-)']] - self.eir_list = [self.eir_list[0]] + self.eir_list # add lowest speed EIR as 'off' EIR + self.eir_list = [kwargs["EIR (-)"]] + self.eir_list = [ + self.eir_list[0] + ] + self.eir_list # add lowest speed EIR as 'off' EIR self.eir = self.eir_list[self.speed_idx] - self.eir_max = self.eir_list[-1] # eir at max capacity (not the largest EIR for multispeed equipment) + self.eir_max = self.eir_list[ + -1 + ] # eir at max capacity (not the largest EIR for multispeed equipment) # SHR (sensible heat ratio), cooling only - shr = kwargs.get('SHR (-)') + shr = kwargs.get("SHR (-)") if shr is None: shr = 1 if isinstance(shr, list): @@ -88,22 +105,38 @@ def __init__(self, envelope_model=None, use_ideal_capacity=None, **kwargs): self.shr = shr_list[self.speed_idx] # Air flow parameters - self.fan_motor_type = kwargs.get('Fan Motor Type') # 'PSC' or 'BPM' + self.fan_motor_type = kwargs.get("Fan Motor Type") # 'PSC' or 'BPM' self.rated_cfm_per_ton = utils_equipment.get_rated_cfm_per_ton(self.name) - if kwargs['Design Airflow (CFM)'] is not None: - self.design_airflow = convert(kwargs['Design Airflow (CFM)'], 'cubic_feet/min', 'm^3/s') # in m^3/s - self.design_cfm_per_ton = kwargs['Design Airflow (CFM)'] / convert(self.capacity_nominal, 'W', 'refrigeration_ton') + if kwargs["Design Airflow (CFM)"] is not None: + self.design_airflow = convert( + kwargs["Design Airflow (CFM)"], "cubic_feet/min", "m^3/s" + ) # in m^3/s + self.design_cfm_per_ton = kwargs["Design Airflow (CFM)"] / convert( + self.capacity_nominal, "W", "refrigeration_ton" + ) else: self.design_cfm_per_ton = utils_equipment.get_design_cfm_per_ton(self.name) - self.design_airflow = convert(self.design_cfm_per_ton * convert(self.capacity_nominal, 'W', 'refrigeration_ton'), 'cubic_feet/min', 'm^3/s') # in m^3/s - - self.flow_rate_list = [self.design_airflow * capacity / self.capacity_nominal for capacity in self.capacity_list] # in m^3/s - self.rated_flow_rate = utils_equipment.calc_rated_airflow(self.capacity_nominal, self.rated_cfm_per_ton, 'm^3/s') # in m^3/s + self.design_airflow = convert( + self.design_cfm_per_ton + * convert(self.capacity_nominal, "W", "refrigeration_ton"), + "cubic_feet/min", + "m^3/s", + ) # in m^3/s + + self.flow_rate_list = [ + self.design_airflow * capacity / self.capacity_nominal + for capacity in self.capacity_list + ] # in m^3/s + self.rated_flow_rate = utils_equipment.calc_rated_airflow( + self.capacity_nominal, self.rated_cfm_per_ton, "m^3/s" + ) # in m^3/s # Fan power parameters - self.fan_power_rated = kwargs['Rated Auxiliary Power (W)'] - self.is_ducted = kwargs.get('Ducted') is not None - self.fan_power_per_flow_rate = self.fan_power_rated / self.design_airflow # in W per m^3/s + self.fan_power_rated = kwargs["Rated Auxiliary Power (W)"] + self.is_ducted = kwargs.get("Ducted") is not None + self.fan_power_per_flow_rate = ( + self.fan_power_rated / self.design_airflow + ) # in W per m^3/s self.fan_power_max = self.fan_power_per_flow_rate * self.flow_rate_list[-1] self.fan_power_list = [ utils_equipment.calculate_fan_power( @@ -115,37 +148,53 @@ def __init__(self, envelope_model=None, use_ideal_capacity=None, **kwargs): for rate in self.flow_rate_list ] # in W self.fan_power = 0 # in W - self.fan_power_ratio = self.fan_power_max / (self.capacity_max * self.eir_max) # For ideal capacity equipment - initial_setpoint = kwargs['initial_schedule'][f'{self.end_use} Setpoint (C)'] - self.coil_input_db = initial_setpoint # Dry bulb temperature after increase from fan power - self.coil_input_wb = initial_setpoint # Wet bulb temperature after increase from fan power + self.fan_power_ratio = self.fan_power_max / ( + self.capacity_max * self.eir_max + ) # For ideal capacity equipment + initial_setpoint = kwargs["initial_schedule"][f"{self.end_use} Setpoint (C)"] + self.coil_input_db = ( + initial_setpoint # Dry bulb temperature after increase from fan power + ) + self.coil_input_wb = ( + initial_setpoint # Wet bulb temperature after increase from fan power + ) # check length of rated lists for speed_list in [self.capacity_list, self.eir_list, self.fan_power_list]: if len(speed_list) - 1 != self.n_speeds: - raise OCHREException(f'Number of speeds ({self.n_speeds}) does not match length of list' - f' ({len(speed_list) - 1})') + raise OCHREException( + f"Number of speeds ({self.n_speeds}) does not match length of list" + f" ({len(speed_list) - 1})" + ) # Duct location and distribution system efficiency (DSE) - ducts = kwargs.get('Ducts', {'DSE (-)': 1}) - self.duct_dse = ducts.get('DSE (-)') # Duct distribution system efficiency - self.duct_zone = self.envelope_model.zones.get(ducts.get('Zone')) + ducts = kwargs.get("Ducts", {"DSE (-)": 1}) + self.duct_dse = ducts.get("DSE (-)") # Duct distribution system efficiency + self.duct_zone = self.envelope_model.zones.get(ducts.get("Zone")) if self.duct_dse is None: - if self.name == 'Room AC': + if self.name == "Room AC": self.duct_dse = 1 else: # Calculate DSE using ASHRAE 152 - self.duct_dse = utils_equipment.calculate_duct_dse(self, ducts, **kwargs) + self.duct_dse = utils_equipment.calculate_duct_dse( + self, ducts, **kwargs + ) if self.duct_dse < 1 and self.duct_zone == self.zone: - self.warn(f'Ignoring duct DSE because ducts are in {self.zone.name} zone.') + self.warn(f"Ignoring duct DSE because ducts are in {self.zone.name} zone.") self.duct_dse = 1 self.duct_zone = None # basement zone heat fraction - basement_zone = self.envelope_model.zones.get('Foundation') + basement_zone = self.envelope_model.zones.get("Foundation") if basement_zone: - default_basement_frac = 0.2 if basement_zone.zone_type == 'Finished Basement' and self.is_heater else 0 - self.basement_heat_frac = kwargs.get('Basement Airflow Ratio (-)', default_basement_frac) + default_basement_frac = ( + 0.2 + if basement_zone.zone_type == "Finished Basement" and self.is_heater + else 0 + ) + self.basement_heat_frac = kwargs.get( + "Basement Airflow Ratio (-)", default_basement_frac + ) else: self.basement_heat_frac = 0 @@ -156,9 +205,13 @@ def __init__(self, envelope_model=None, use_ideal_capacity=None, **kwargs): self.zone_fractions[self.duct_zone] = 1 - self.duct_dse if self.basement_heat_frac > 0: if basement_zone == self.duct_zone: - self.zone_fractions[basement_zone] += self.duct_dse * self.basement_heat_frac + self.zone_fractions[basement_zone] += ( + self.duct_dse * self.basement_heat_frac + ) else: - self.zone_fractions[basement_zone] = self.duct_dse * self.basement_heat_frac + self.zone_fractions[basement_zone] = ( + self.duct_dse * self.basement_heat_frac + ) # Coil Ao factor, cooling only if self.is_heater: @@ -167,11 +220,21 @@ def __init__(self, envelope_model=None, use_ideal_capacity=None, **kwargs): rated_dry_bulb = utils_equipment.AIR_SOURCE_COOL_RATED_IDB # in degrees C rated_wet_bulb = utils_equipment.AIR_SOURCE_COOL_RATED_IWB # in degrees C rated_pressure = 101.3 # in kPa - rated_w = psychrolib.GetHumRatioFromTWetBulb(rated_dry_bulb, rated_wet_bulb, rated_pressure * 1000) + rated_w = psychrolib.GetHumRatioFromTWetBulb( + rated_dry_bulb, rated_wet_bulb, rated_pressure * 1000 + ) ao_data = zip(self.capacity_list[1:], self.flow_rate_list[1:], shr_list[1:]) - ao_list = [utils_equipment.coil_ao_factor(rated_dry_bulb, rated_w, rated_pressure, - capacity / 1000, flow_rate, shr) - for capacity, flow_rate, shr in ao_data] + ao_list = [ + utils_equipment.coil_ao_factor( + rated_dry_bulb, + rated_w, + rated_pressure, + capacity / 1000, + flow_rate, + shr, + ) + for capacity, flow_rate, shr in ao_data + ] self.Ao_list = [ao_list[0]] + ao_list else: # for ideal coolers @@ -179,19 +242,19 @@ def __init__(self, envelope_model=None, use_ideal_capacity=None, **kwargs): # Thermostat Control Parameters self.temp_setpoint = initial_setpoint - self.temp_deadband = kwargs.get('Deadband Temperature (C)', 1) + self.temp_deadband = kwargs.get("Deadband Temperature (C)", 1) # Offset defines setpoint overshoot, 0.2 means setpoint is near top of deadband for heating # Offset defaults to reflect lab results - self.deadband_offset = kwargs.get("Deadband Offset (C)", 0.2) - self.ext_ignore_thermostat = kwargs.get('ext_ignore_thermostat', False) - #self.setpoint_ramp_rate = kwargs.get('setpoint_ramp_rate') # max setpoint ramp rate, in C/min + self.deadband_offset = kwargs.get("Deadband Offset (C)", 0.2) + self.ext_ignore_thermostat = kwargs.get("ext_ignore_thermostat", False) + # self.setpoint_ramp_rate = kwargs.get('setpoint_ramp_rate') # max setpoint ramp rate, in C/min self.temp_indoor_prev = self.temp_setpoint - self.mode_prev = 'Off' #assumed initial value, updated later in results + self.mode_prev = "Off" # assumed initial value, updated later in results self.ext_capacity = None # Option to set capacity directly, ideal capacity only self.ext_capacity_frac = 1 # Option to limit max capacity, ideal capacity only # Results options - self.show_eir_shr = kwargs.get('show_eir_shr', False) + self.show_eir_shr = kwargs.get("show_eir_shr", False) # if main simulator, add envelope as sub simulator if self.main_simulator: @@ -200,20 +263,26 @@ def __init__(self, envelope_model=None, use_ideal_capacity=None, **kwargs): # Use ideal or static/dynamic capacity depending on time resolution and number of speeds # 4 speeds are used for variable speed equipment, which must use ideal capacity if use_ideal_capacity is None: - use_ideal_capacity = self.time_res >= dt.timedelta(minutes=5) or self.n_speeds >= 4 + use_ideal_capacity = ( + self.time_res >= dt.timedelta(minutes=5) or self.n_speeds >= 4 + ) self.use_ideal_capacity = use_ideal_capacity def initialize_schedule(self, schedule=None, **kwargs): # Compile all HVAC required inputs - required_inputs = [f'{self.end_use} Setpoint (C)'] + required_inputs = [f"{self.end_use} Setpoint (C)"] if isinstance(self, DynamicHVAC): - required_inputs.append('Ambient Dry Bulb (C)') - if isinstance(self, HeatPumpHeater) or (not self.is_heater and self.zone.humidity is None): + required_inputs.append("Ambient Dry Bulb (C)") + if isinstance(self, HeatPumpHeater) or ( + not self.is_heater and self.zone.humidity is None + ): # Required for heat pump heater and dynamic AC if humidity model not included - required_inputs.append('Ambient Humidity Ratio (-)') - required_inputs.append('Ambient Pressure (kPa)') + required_inputs.append("Ambient Humidity Ratio (-)") + required_inputs.append("Ambient Pressure (kPa)") - return super().initialize_schedule(schedule, required_inputs=required_inputs, **kwargs) + return super().initialize_schedule( + schedule, required_inputs=required_inputs, **kwargs + ) def update_external_control(self, control_signal): # Options for external control signals: @@ -231,18 +300,18 @@ def update_external_control(self, control_signal): # - For ASHP: Can supply HP and ER duty cycles # - Note: does not use clock on/off time - ext_setpoint = control_signal.get('Setpoint') + ext_setpoint = control_signal.get("Setpoint") if ext_setpoint is not None: - self.current_schedule[f'{self.end_use} Setpoint (C)'] = ext_setpoint + self.current_schedule[f"{self.end_use} Setpoint (C)"] = ext_setpoint - ext_db = control_signal.get('Deadband') + ext_db = control_signal.get("Deadband") if ext_db is not None: - if f'{self.end_use} Deadband (C)' in self.current_schedule: - self.current_schedule[f'{self.end_use} Deadband (C)'] = ext_db + if f"{self.end_use} Deadband (C)" in self.current_schedule: + self.current_schedule[f"{self.end_use} Deadband (C)"] = ext_db else: self.temp_deadband = ext_db - capacity_frac = control_signal.get('Max Capacity Fraction') + capacity_frac = control_signal.get("Max Capacity Fraction") if capacity_frac is not None: if not self.use_ideal_capacity: raise IOError( @@ -250,11 +319,13 @@ def update_external_control(self, control_signal): 'Set `use_ideal_capacity` to True or control "Duty Cycle".' ) if f"{self.end_use} Max Capacity Fraction (-)" in self.current_schedule: - self.current_schedule[f"{self.end_use} Max Capacity Fraction (-)"] = capacity_frac + self.current_schedule[f"{self.end_use} Max Capacity Fraction (-)"] = ( + capacity_frac + ) else: self.ext_capacity_frac = capacity_frac - capacity = control_signal.get('Capacity') + capacity = control_signal.get("Capacity") if capacity is not None: if not self.use_ideal_capacity: raise IOError( @@ -273,7 +344,7 @@ def update_external_control(self, control_signal): elif load_fraction != 1: raise OCHREException(f"{self.name} can't handle non-integer load fractions") - if any(['Duty Cycle' in key for key in control_signal]): + if any(["Duty Cycle" in key for key in control_signal]): if self.use_ideal_capacity: raise IOError( f"Cannot set {self.name} Duty Cycle. " @@ -285,15 +356,15 @@ def update_external_control(self, control_signal): return self.update_internal_control() def parse_duty_cycles(self, control_signal): - return control_signal.get('Duty Cycle', 0) - + return control_signal.get("Duty Cycle", 0) + def run_duty_cycle_control(self, duty_cycles): if duty_cycles == 0: self.speed_idx = 0 - return 'Off' + return "Off" if duty_cycles == 1: self.speed_idx = self.n_speeds # max speed - return 'On' + return "On" # Parse duty cycles if isinstance(duty_cycles, (int, float)): @@ -307,11 +378,14 @@ def run_duty_cycle_control(self, duty_cycles): thermostat_mode = thermostat_mode if thermostat_mode is not None else self.mode # take thermostat mode if it exists in priority stack, or take highest priority mode (usually current mode) - mode = thermostat_mode if (thermostat_mode in mode_priority and - not self.ext_ignore_thermostat) else mode_priority[0] + mode = ( + thermostat_mode + if (thermostat_mode in mode_priority and not self.ext_ignore_thermostat) + else mode_priority[0] + ) # by default, turn on to max speed - self.speed_idx = self.n_speeds if 'On' in mode else 0 + self.speed_idx = self.n_speeds if "On" in mode else 0 return mode @@ -324,19 +398,19 @@ def update_internal_control(self): # FUTURE: capacity update is done twice per loop, could but updated to improve speed self.capacity = self.update_capacity() - return 'On' if self.capacity > 0 else 'Off' + return "On" if self.capacity > 0 else "Off" else: # Run thermostat controller and set speed return self.run_thermostat_control() def update_setpoint(self): - t_set = self.current_schedule[f'{self.end_use} Setpoint (C)'] - if f'{self.end_use} Deadband (C)' in self.current_schedule: - self.temp_deadband = self.current_schedule[f'{self.end_use} Deadband (C)'] + t_set = self.current_schedule[f"{self.end_use} Setpoint (C)"] + if f"{self.end_use} Deadband (C)" in self.current_schedule: + self.temp_deadband = self.current_schedule[f"{self.end_use} Deadband (C)"] # updates setpoint with ramp rate constraints - # TODO: create temp_setpoint_old and update in update_results. + # TODO: create temp_setpoint_old and update in update_results. # Could get run multiple times per time step in update_model self.temp_setpoint = t_set @@ -356,20 +430,24 @@ def run_thermostat_control(self, setpoint=None): setpoint = self.temp_setpoint # On and off limits depend on heating vs. cooling - temp_turn_on = setpoint - self.hvac_mult * self.temp_deadband * (1 - self.deadband_offset) - temp_turn_off = setpoint + self.hvac_mult * self.temp_deadband * (self.deadband_offset) + temp_turn_on = setpoint - self.hvac_mult * self.temp_deadband * ( + 1 - self.deadband_offset + ) + temp_turn_off = setpoint + self.hvac_mult * self.temp_deadband * ( + self.deadband_offset + ) # Determine mode if self.hvac_mult * (self.zone.temperature - temp_turn_on) < 0: # by default, set to max speed self.speed_idx = self.n_speeds - return 'On' + return "On" elif self.hvac_mult * (self.zone.temperature - temp_turn_off) > 0: self.speed_idx = 0 - return 'Off' + return "Off" else: return None - + def solve_ideal_capacity(self): # Update capacity using ideal algorithm - maintains setpoint exactly x_desired = self.temp_setpoint @@ -381,7 +459,9 @@ def solve_ideal_capacity(self): zone_ratios = list(self.zone_fractions.values()) # Note: h_desired should be equal to self.delivered_heat - h_desired = self.envelope_model.solve_for_inputs(self.zone.t_idx, zone_idxs, x_desired, zone_ratios) # in W + h_desired = self.envelope_model.solve_for_inputs( + self.zone.t_idx, zone_idxs, x_desired, zone_ratios + ) # in W # Account for fan power and SHR - slightly different for heating/cooling # assumes SHR and EIR from previous time step @@ -395,7 +475,7 @@ def update_capacity(self): # Solve for capacity to meet setpoint self.capacity_ideal = self.solve_ideal_capacity() capacity = self.capacity_ideal - + # Update from direct capacity controls if self.ext_capacity is not None: capacity = self.ext_capacity @@ -428,8 +508,8 @@ def update_shr(self): pres_int = self.zone.humidity.pressure else: # use ambient conditions if no humidity model defined - w_in = self.current_schedule['Ambient Humidity Ratio (-)'] - pres_int = self.current_schedule['Ambient Pressure (kPa)'] * 1000 # in Pa + w_in = self.current_schedule["Ambient Humidity Ratio (-)"] + pres_int = self.current_schedule["Ambient Pressure (kPa)"] * 1000 # in Pa if w_in == 0: return 1 @@ -437,29 +517,41 @@ def update_shr(self): if self.fan_power_max: # calculate increased dry and wet bulb temperatures due to fan power self.coil_input_db += self.fan_power_per_flow_rate / 1000 / rho_air / cp_air - self.coil_input_wb = psychrolib.GetTWetBulbFromHumRatio(self.coil_input_db, w_in, pres_int) + self.coil_input_wb = psychrolib.GetTWetBulbFromHumRatio( + self.coil_input_db, w_in, pres_int + ) elif self.zone.humidity is not None: # Don't recalculate wet bulb if already done in humidity model self.coil_input_wb = self.zone.humidity.wet_bulb else: - self.coil_input_wb = psychrolib.GetTWetBulbFromHumRatio(self.coil_input_db, w_in, pres_int) + self.coil_input_wb = psychrolib.GetTWetBulbFromHumRatio( + self.coil_input_db, w_in, pres_int + ) # Calculate SHR based on speed speed_low = int(self.speed_idx // 1) # 0 is the lowest speed - shr_low = utils_equipment.calculate_shr(self.coil_input_db, w_in, pres_int / 1000, - self.capacity_list[speed_low] / 1000, - self.flow_rate_list[speed_low], - self.Ao_list[speed_low]) + shr_low = utils_equipment.calculate_shr( + self.coil_input_db, + w_in, + pres_int / 1000, + self.capacity_list[speed_low] / 1000, + self.flow_rate_list[speed_low], + self.Ao_list[speed_low], + ) frac_high = self.speed_idx % 1 if frac_high: # take a weighted average of 2 closest speeds based on speed_idx. Note speed_idx=0 means off (capacity=0) speed_high = speed_low + 1 - shr_high = utils_equipment.calculate_shr(self.coil_input_db, w_in, pres_int / 1000, - self.capacity_list[speed_high] / 1000, - self.flow_rate_list[speed_high], - self.Ao_list[speed_high]) - shr = ((1 - frac_high) * shr_low + frac_high * shr_high) + shr_high = utils_equipment.calculate_shr( + self.coil_input_db, + w_in, + pres_int / 1000, + self.capacity_list[speed_high] / 1000, + self.flow_rate_list[speed_high], + self.Ao_list[speed_high], + ) + shr = (1 - frac_high) * shr_low + frac_high * shr_high else: shr = shr_low @@ -479,7 +571,7 @@ def update_eir(self): def calculate_power_and_heat(self): # Calculate delivered heat to envelope model - if 'On' in self.mode: + if "On" in self.mode: self.shr = self.update_shr() self.capacity = self.update_capacity() self.fan_power = self.update_fan_power(self.capacity) @@ -491,7 +583,9 @@ def calculate_power_and_heat(self): self.shr = self.update_shr() self.eir = self.update_eir() - heat_gain = self.hvac_mult * self.capacity # Heat gain in W, positive=heat, negative=cool + heat_gain = ( + self.hvac_mult * self.capacity + ) # Heat gain in W, positive=heat, negative=cool # Calculate total sensible and latent heat self.delivered_heat = heat_gain * self.shr + self.fan_power # SHR=1 for fan @@ -521,36 +615,48 @@ def add_gains_to_zone(self): def generate_results(self): results = super().generate_results() - on = 'On' in self.mode + on = "On" in self.mode if self.verbosity >= 4: # add delivered heat, setpoint, and COP # recalculate COP to account for any changes in power (e.g. crankcase, pan heater) - main_power = self.electric_kw + self.gas_therms_per_hour / kwh_to_therms - self.fan_power / 1000 + main_power = ( + self.electric_kw + + self.gas_therms_per_hour / kwh_to_therms + - self.fan_power / 1000 + ) if on and main_power != 0: cop = self.capacity * self.space_fraction / main_power / 1000 elif self.show_eir_shr: cop = 1 / self.eir else: cop = 0 - results[f'{self.end_use} Delivered (W)'] = abs(self.delivered_heat) * self.duct_dse - results[f'{self.end_use} Setpoint (C)'] = self.temp_setpoint - results[f'{self.end_use} COP (-)'] = cop + results[f"{self.end_use} Delivered (W)"] = ( + abs(self.delivered_heat) * self.duct_dse + ) + results[f"{self.end_use} Setpoint (C)"] = self.temp_setpoint + results[f"{self.end_use} COP (-)"] = cop if self.verbosity >= 5: # add component loads (ducts) - results[f'{self.end_use} Duct Losses (W)'] = abs(self.delivered_heat) * (1 - self.duct_dse) + results[f"{self.end_use} Duct Losses (W)"] = abs(self.delivered_heat) * ( + 1 - self.duct_dse + ) if self.verbosity >= 7: # add other results - results[f'{self.end_use} Main Power (kW)'] = main_power - results[f'{self.end_use} Fan Power (kW)'] = self.fan_power / 1000 + results[f"{self.end_use} Main Power (kW)"] = main_power + results[f"{self.end_use} Fan Power (kW)"] = self.fan_power / 1000 if not self.is_heater: - results[f'{self.end_use} Latent Gains (W)'] = self.latent_gain * self.space_fraction - results[f'{self.end_use} SHR (-)'] = self.shr if on or self.show_eir_shr else 0 - results[f'{self.end_use} Speed (-)'] = self.speed_idx - results[f'{self.end_use} Capacity (W)'] = self.capacity - results[f'{self.end_use} Max Capacity (W)'] = self.capacity_max + results[f"{self.end_use} Latent Gains (W)"] = ( + self.latent_gain * self.space_fraction + ) + results[f"{self.end_use} SHR (-)"] = ( + self.shr if on or self.show_eir_shr else 0 + ) + results[f"{self.end_use} Speed (-)"] = self.speed_idx + results[f"{self.end_use} Capacity (W)"] = self.capacity + results[f"{self.end_use} Max Capacity (W)"] = self.capacity_max if self.save_ebm_results: results.update(self.make_equivalent_battery_model()) @@ -566,7 +672,7 @@ def update_results(self): # update previous indoor temperature self.temp_indoor_prev = self.zone.temperature - #update previous mode + # update previous mode self.mode_prev = self.mode return current_results @@ -578,22 +684,35 @@ def make_equivalent_battery_model(self): # TODO: Baseline power calculation should assume no change in indoor temperature setpoint # TODO: update capacitance using 1R1C model ref_temp = 10 if self.is_heater else 30 # temperature at Energy=0, in C - total_capacitance = convert(self.zone.capacitance, 'kJ', 'kWh') # in kWh/K - max_temp = self.temp_setpoint + self.hvac_mult * self.temp_deadband * (1 - self.deadband_offset) # "turn off" temperature - min_temp = self.temp_setpoint - self.hvac_mult * self.temp_deadband * self.deadband_offset # "turn on" temperature + total_capacitance = convert(self.zone.capacitance, "kJ", "kWh") # in kWh/K + max_temp = self.temp_setpoint + self.hvac_mult * self.temp_deadband * ( + 1 - self.deadband_offset + ) # "turn off" temperature + min_temp = ( + self.temp_setpoint + - self.hvac_mult * self.temp_deadband * self.deadband_offset + ) # "turn on" temperature return { - f'{self.end_use} EBM Energy (kWh)': total_capacitance * (self.zone.temperature - ref_temp) * self.hvac_mult, - f'{self.end_use} EBM Min Energy (kWh)': total_capacitance * (min_temp - ref_temp) * self.hvac_mult, - f'{self.end_use} EBM Max Energy (kWh)': total_capacitance * (max_temp - ref_temp) * self.hvac_mult, - f'{self.end_use} EBM Max Power (kW)': self.capacity_max * self.eir / 1000, - f'{self.end_use} EBM Efficiency (-)': 1 / self.eir, - f'{self.end_use} EBM Baseline Power (kW)': self.capacity_ideal if self.use_ideal_capacity else None, + f"{self.end_use} EBM Energy (kWh)": total_capacitance + * (self.zone.temperature - ref_temp) + * self.hvac_mult, + f"{self.end_use} EBM Min Energy (kWh)": total_capacitance + * (min_temp - ref_temp) + * self.hvac_mult, + f"{self.end_use} EBM Max Energy (kWh)": total_capacitance + * (max_temp - ref_temp) + * self.hvac_mult, + f"{self.end_use} EBM Max Power (kW)": self.capacity_max * self.eir / 1000, + f"{self.end_use} EBM Efficiency (-)": 1 / self.eir, + f"{self.end_use} EBM Baseline Power (kW)": self.capacity_ideal + if self.use_ideal_capacity + else None, } class Heater(HVAC): - end_use = 'HVAC Heating' - name = 'Generic Heater' + end_use = "HVAC Heating" + name = "Generic Heater" optional_inputs = [ "HVAC Heating Deadband (C)", "HVAC Heating Capacity (W)", @@ -602,8 +721,8 @@ class Heater(HVAC): class Cooler(HVAC): - end_use = 'HVAC Cooling' - name = 'Generic Cooler' + end_use = "HVAC Cooling" + name = "Generic Cooler" optional_inputs = [ "HVAC Cooling Deadband (C)", "HVAC Cooling Capacity (W)", @@ -612,15 +731,15 @@ class Cooler(HVAC): class ElectricFurnace(Heater): - name = 'Electric Furnace' + name = "Electric Furnace" class ElectricBoiler(Heater): - name = 'Electric Boiler' + name = "Electric Boiler" class ElectricBaseboard(Heater): - name = 'Electric Baseboard' + name = "Electric Baseboard" def __init__(self, **kwargs): super().__init__(**kwargs) @@ -630,28 +749,52 @@ def __init__(self, **kwargs): class GasFurnace(Heater): - name = 'Gas Furnace' + name = "Gas Furnace" is_gas = True class GasBoiler(Heater): - name = 'Gas Boiler' + name = "Gas Boiler" is_gas = True def __init__(self, **kwargs): super().__init__(**kwargs) # Boiler specific inputs - self.condensing = self.eir_max < 1 / 0.9 # Condensing if efficiency (AFUE) > 90% + self.condensing = ( + self.eir_max < 1 / 0.9 + ) # Condensing if efficiency (AFUE) > 90% if self.condensing: self.outlet_temp = 65.56 # outlet_water_temp [C] (150 F) - self.efficiency_coeff = np.array([1.058343061, -0.052650153, -0.0087272, - -0.001742217, 0.00000333715, 0.000513723], dtype=float) + self.efficiency_coeff = np.array( + [ + 1.058343061, + -0.052650153, + -0.0087272, + -0.001742217, + 0.00000333715, + 0.000513723, + ], + dtype=float, + ) else: self.outlet_temp = 82.22 # self.outlet_water_temp [C] (180F) - self.efficiency_coeff = np.array([1.111720116, 0.078614078, -0.400425756, 0, -0.000156783, 0.009384599, - 0.234257955, 0.00000132927, -0.004446701, -0.0000122498], dtype=float) + self.efficiency_coeff = np.array( + [ + 1.111720116, + 0.078614078, + -0.400425756, + 0, + -0.000156783, + 0.009384599, + 0.234257955, + 0.00000132927, + -0.004446701, + -0.0000122498, + ], + dtype=float, + ) def update_eir(self): # update EIR based on part load ratio, input/output temperatures @@ -659,11 +802,24 @@ def update_eir(self): t_in = self.zone.temperature t_out = self.outlet_temp if self.condensing: - eff_var = np.array([1, plr, plr ** 2, t_in, t_in ** 2, plr * t_in], dtype=float) + eff_var = np.array([1, plr, plr**2, t_in, t_in**2, plr * t_in], dtype=float) eff_curve_output = np.dot(eff_var, self.efficiency_coeff) else: - eff_var = np.array([1, plr, plr ** 2, t_out, t_out ** 2, plr * t_out, plr ** 3, t_out ** 3, - plr ** 2 * t_out, plr * t_out ** 2], dtype=float) + eff_var = np.array( + [ + 1, + plr, + plr**2, + t_out, + t_out**2, + plr * t_out, + plr**3, + t_out**3, + plr**2 * t_out, + plr * t_out**2, + ], + dtype=float, + ) eff_curve_output = np.dot(eff_var, self.efficiency_coeff) return self.eir_max / eff_curve_output @@ -681,31 +837,43 @@ class DynamicHVAC(HVAC): https://www1.eere.energy.gov/buildings/publications/pdfs/building_america/modeling_ac_heatpump.pdf Section 2.2.1, Equations 7-8 and 11-13 """ + SPEED_INDEX_MAP = { 1: utils_equipment.HPXML_SPEED_DESCRIPTION_MINIMUM, 2: utils_equipment.HPXML_SPEED_DESCRIPTION_NOMINAL, - 3: utils_equipment.HPXML_SPEED_DESCRIPTION_MAXIMUM - } # add 1 to speed index to include off speed in capacity list + 3: utils_equipment.HPXML_SPEED_DESCRIPTION_MAXIMUM, + } # add 1 to speed index to include off speed in capacity list - def __init__(self, control_type='Time', **kwargs): + def __init__(self, control_type="Time", **kwargs): # Get number of speeds - self.n_speeds = kwargs.get('Number of Speeds (-)', 1) + self.n_speeds = kwargs.get("Number of Speeds (-)", 1) # 2-speed control type and timing variables self.control_type = control_type # 'Time', 'Time2', or 'Setpoint' - self.disable_speeds = np.zeros(self.n_speeds, dtype=bool) # if True, disable that speed + self.disable_speeds = np.zeros( + self.n_speeds, dtype=bool + ) # if True, disable that speed self.time_in_speed = dt.timedelta(0) - min_time_in_low = kwargs.get('Minimum Low Time (minutes)', 5) - min_time_in_high = kwargs.get('Minimum High Time (minutes)', 5) - self.min_time_in_speed = [dt.timedelta(minutes=min_time_in_low), dt.timedelta(minutes=min_time_in_high)] + min_time_in_low = kwargs.get("Minimum Low Time (minutes)", 5) + min_time_in_high = kwargs.get("Minimum High Time (minutes)", 5) + self.min_time_in_speed = [ + dt.timedelta(minutes=min_time_in_low), + dt.timedelta(minutes=min_time_in_high), + ] self.datapoint_by_speed_htg = None self.datapoint_by_speed_clg = None - self.detailed_performance_data_htg = kwargs.get('HeatingDetailedPerformance', None) - self.detailed_performance_data_clg = kwargs.get('CoolingDetailedPerformance', None) + self.detailed_performance_data_htg = kwargs.get( + "HeatingDetailedPerformance", None + ) + self.detailed_performance_data_clg = kwargs.get( + "CoolingDetailedPerformance", None + ) # startup capacity degradation parameters self.startup_cap_mult = 1.0 # multiplier, unitless - self.c_d = kwargs.get("Startup Capacity Degradation (-)", 0.0) # degradation factor, unitless - if kwargs.get('Disable HVAC Biquadratics', False): + self.c_d = kwargs.get( + "Startup Capacity Degradation (-)", 0.0 + ) # degradation factor, unitless + if kwargs.get("Disable HVAC Biquadratics", False): self.eir_t = None self.eir_ff = None self.eir_plr = None @@ -713,46 +881,114 @@ def __init__(self, control_type='Time', **kwargs): self.cap_ff = None else: # Adding 1 for off speed in capacity list, but biquadratic curves only apply to on speeds - self.eir_t = [PerformanceCurve('biquadratic', 'temperature') for _ in range(self.n_speeds + 1)] - self.eir_ff = [PerformanceCurve('quadratic', 'fraction') for _ in range(self.n_speeds + 1)] - self.eir_plr = [PerformanceCurve('quadratic', 'fraction') for _ in range(self.n_speeds + 1)] - self.cap_t = [PerformanceCurve('biquadratic', 'temperature') for _ in range(self.n_speeds + 1)] - self.cap_ff = [PerformanceCurve('quadratic', 'fraction') for _ in range(self.n_speeds + 1)] - + self.eir_t = [ + PerformanceCurve("biquadratic", "temperature") + for _ in range(self.n_speeds + 1) + ] + self.eir_ff = [ + PerformanceCurve("quadratic", "fraction") + for _ in range(self.n_speeds + 1) + ] + self.eir_plr = [ + PerformanceCurve("quadratic", "fraction") + for _ in range(self.n_speeds + 1) + ] + self.cap_t = [ + PerformanceCurve("biquadratic", "temperature") + for _ in range(self.n_speeds + 1) + ] + self.cap_ff = [ + PerformanceCurve("quadratic", "fraction") + for _ in range(self.n_speeds + 1) + ] + if self.detailed_performance_data_htg: # TODO: implement detailed performance curves - rated_dp = kwargs['HeatingDetailedPerformance'][utils_equipment.AIR_SOURCE_HEAT_RATED_ODB] - kwargs['Capacity (W)'] = [speed_data['capacity'] for speed_data in rated_dp.values()] - kwargs['EIR (-)'] = [1 / speed_data['COP'] for speed_data in rated_dp.values()] - kwargs['SHR (-)'] = [0.708] * len(kwargs['Capacity (W)']) + rated_dp = kwargs["HeatingDetailedPerformance"][ + utils_equipment.AIR_SOURCE_HEAT_RATED_ODB + ] + kwargs["Capacity (W)"] = [ + speed_data["capacity"] for speed_data in rated_dp.values() + ] + kwargs["EIR (-)"] = [ + 1 / speed_data["COP"] for speed_data in rated_dp.values() + ] + kwargs["SHR (-)"] = [0.708] * len(kwargs["Capacity (W)"]) elif self.detailed_performance_data_clg: - rated_dp = kwargs['CoolingDetailedPerformance'][utils_equipment.AIR_SOURCE_COOL_RATED_ODB] - kwargs['Capacity (W)'] = [speed_data['capacity'] for speed_data in rated_dp.values()] - kwargs['EIR (-)'] = [1 / speed_data['COP'] for speed_data in rated_dp.values()] - kwargs['SHR (-)'] = [0.708] * len(kwargs['Capacity (W)']) - elif not kwargs.get('Disable HVAC Biquadratics', False): - if self.name != 'Room AC': - raise OCHREException('Detailed performance data is required for dynamic HVAC equipment other than Room AC.') + rated_dp = kwargs["CoolingDetailedPerformance"][ + utils_equipment.AIR_SOURCE_COOL_RATED_ODB + ] + kwargs["Capacity (W)"] = [ + speed_data["capacity"] for speed_data in rated_dp.values() + ] + kwargs["EIR (-)"] = [ + 1 / speed_data["COP"] for speed_data in rated_dp.values() + ] + kwargs["SHR (-)"] = [0.708] * len(kwargs["Capacity (W)"]) + elif not kwargs.get("Disable HVAC Biquadratics", False): + if self.name != "Room AC": + raise OCHREException( + "Detailed performance data is required for dynamic HVAC equipment other than Room AC." + ) else: # replace with Cutler curves, SI units - self.eir_t[1].set(coefficients=np.array([-0.350447695, 0.116809893, -0.00339950844, -0.001226088, 0.0006008094, -0.000466884], dtype=float)) + self.eir_t[1].set( + coefficients=np.array( + [ + -0.350447695, + 0.116809893, + -0.00339950844, + -0.001226088, + 0.0006008094, + -0.000466884, + ], + dtype=float, + ) + ) self.eir_ff[1].set(coefficients=np.array([1.0, 0.0, 0.0], dtype=float)) - self.eir_plr[1].set(coefficients=np.array([0.78, 0.22, 0], dtype=float), min_y=0.7, max_y=1.0) - self.cap_t[1].set(coefficients=np.array([1.557359706, -0.0744481692, 0.00309859668, 0.0014595786, -0.000041148, -0.00042671448], dtype=float)) + self.eir_plr[1].set( + coefficients=np.array([0.78, 0.22, 0], dtype=float), + min_y=0.7, + max_y=1.0, + ) + self.cap_t[1].set( + coefficients=np.array( + [ + 1.557359706, + -0.0744481692, + 0.00309859668, + 0.0014595786, + -0.000041148, + -0.00042671448, + ], + dtype=float, + ) + ) self.cap_ff[1].set(coefficients=np.array([1.0, 0.0, 0.0], dtype=float)) super().__init__(**kwargs) if self.detailed_performance_data_htg: - self.datapoint_by_speed_htg = utils_equipment.process_detailed_performance_data(self.detailed_performance_data_htg, 'Heating', self.capacity_nominal, self.rated_flow_rate, self.capacity_list, self.fan_power_per_flow_rate, self.fan_motor_type, self.is_ducted) + self.datapoint_by_speed_htg = ( + utils_equipment.process_detailed_performance_data( + self.detailed_performance_data_htg, + "Heating", + self.capacity_nominal, + self.rated_flow_rate, + self.capacity_list, + self.fan_power_per_flow_rate, + self.fan_motor_type, + self.is_ducted, + ) + ) for index, eir_t_curve in enumerate(self.eir_t): if index == 0: continue # skip off speed eir_t_curve.set( datapoints=self.datapoint_by_speed_htg[self.SPEED_INDEX_MAP[index]], rated_value=self.eir_list[index], - output_key='gross_EIR', - hvac_mode='Heating', + output_key="gross_EIR", + hvac_mode="Heating", ) for index, cap_t_curve in enumerate(self.cap_t): if index == 0: @@ -760,25 +996,48 @@ def __init__(self, control_type='Time', **kwargs): cap_t_curve.set( datapoints=self.datapoint_by_speed_htg[self.SPEED_INDEX_MAP[index]], rated_value=self.capacity_list[index], - output_key='gross_capacity', - hvac_mode='Heating', + output_key="gross_capacity", + hvac_mode="Heating", ) for cap_ff in self.cap_ff: - cap_ff.set(coefficients=np.array([0.694045465, 0.474207981, -0.168253446], dtype=float)) + cap_ff.set( + coefficients=np.array( + [0.694045465, 0.474207981, -0.168253446], dtype=float + ) + ) for eir_ff in self.eir_ff: - eir_ff.set(coefficients=np.array([2.185418751, -1.942827919, 0.757409168], dtype=float)) + eir_ff.set( + coefficients=np.array( + [2.185418751, -1.942827919, 0.757409168], dtype=float + ) + ) for eir_plr in self.eir_plr: - eir_plr.set(coefficients=np.array([(1.0 - self.c_d), self.c_d, 0.0], dtype=float)) + eir_plr.set( + coefficients=np.array( + [(1.0 - self.c_d), self.c_d, 0.0], dtype=float + ) + ) if self.detailed_performance_data_clg: - self.datapoint_by_speed_clg = utils_equipment.process_detailed_performance_data(self.detailed_performance_data_clg, 'Cooling', self.capacity_nominal, self.rated_flow_rate, self.capacity_list, self.fan_power_per_flow_rate, self.fan_motor_type, self.is_ducted) + self.datapoint_by_speed_clg = ( + utils_equipment.process_detailed_performance_data( + self.detailed_performance_data_clg, + "Cooling", + self.capacity_nominal, + self.rated_flow_rate, + self.capacity_list, + self.fan_power_per_flow_rate, + self.fan_motor_type, + self.is_ducted, + ) + ) for index, eir_t_curve in enumerate(self.eir_t): if index == 0: continue # skip off speed eir_t_curve.set( datapoints=self.datapoint_by_speed_clg[self.SPEED_INDEX_MAP[index]], rated_value=self.eir_list[index], - output_key='gross_EIR', - hvac_mode='Cooling', + output_key="gross_EIR", + hvac_mode="Cooling", ) for index, cap_t_curve in enumerate(self.cap_t): if index == 0: @@ -786,24 +1045,38 @@ def __init__(self, control_type='Time', **kwargs): cap_t_curve.set( datapoints=self.datapoint_by_speed_clg[self.SPEED_INDEX_MAP[index]], rated_value=self.capacity_list[index], - output_key='gross_capacity', - hvac_mode='Cooling', + output_key="gross_capacity", + hvac_mode="Cooling", ) for cap_ff in self.cap_ff: - cap_ff.set(coefficients=np.array([0.718664047, 0.41797409, -0.136638137], dtype=float)) + cap_ff.set( + coefficients=np.array( + [0.718664047, 0.41797409, -0.136638137], dtype=float + ) + ) for eir_ff in self.eir_ff: - eir_ff.set(coefficients=np.array([1.143487507, -0.13943972, -0.004047787], dtype=float)) + eir_ff.set( + coefficients=np.array( + [1.143487507, -0.13943972, -0.004047787], dtype=float + ) + ) for eir_plr in self.eir_plr: - eir_plr.set(coefficients=np.array([(1.0 - self.c_d), self.c_d, 0.0], dtype=float)) + eir_plr.set( + coefficients=np.array( + [(1.0 - self.c_d), self.c_d, 0.0], dtype=float + ) + ) # Check EIR and print warning if too low if self.eir_max > 0.5: self.warn("Low EIR:", self.eir_max, "(at full capacity)") - if kwargs.get('Disable HVAC Part Load Factor', False): + if kwargs.get("Disable HVAC Part Load Factor", False): # for minimal tests, disable PLF for eir_plr_curve in self.eir_plr: - eir_plr_curve.set(coefficients=np.array([1, 0, 0], dtype=float), datapoints=None) + eir_plr_curve.set( + coefficients=np.array([1, 0, 0], dtype=float), datapoints=None + ) def update_external_control(self, control_signal): # Options for external control signals: @@ -811,7 +1084,9 @@ def update_external_control(self, control_signal): # - Note: Can be used for ideal equipment (reduces max capacity) or dynamic equipment # - Note: Disable Speeds will not reset back to original value for idx in range(self.n_speeds): - self.disable_speeds[idx] = bool(control_signal.get(f'Disable Speed {idx + 1}')) + self.disable_speeds[idx] = bool( + control_signal.get(f"Disable Speed {idx + 1}") + ) return super().update_external_control(control_signal) @@ -824,9 +1099,9 @@ def run_two_speed_control(self): # mode = mode if mode is not None else self.mode prev_speed_idx = self.speed_idx - if self.control_type == 'Time': + if self.control_type == "Time": # Time-based 2-speed HVAC control: High speed turns on if temp continues to drop (for heating) - if self.mode == 'Off': + if self.mode == "Off": speed = 1 elif self.hvac_mult * (self.zone.temperature - self.temp_indoor_prev) < 0: speed = 2 @@ -840,23 +1115,27 @@ def run_two_speed_control(self): # speed_idx = 1 # else: # speed_idx = 0 - elif self.control_type == 'Setpoint': + elif self.control_type == "Setpoint": # Setpoint-based 2-speed HVAC control: High speed uses setpoint difference of deadband * deadband_offset (overlapping) - high_mode = super().run_thermostat_control(self.temp_setpoint - self.hvac_mult * self.deadband_offset) - if high_mode == 'On': + high_mode = super().run_thermostat_control( + self.temp_setpoint - self.hvac_mult * self.deadband_offset + ) + if high_mode == "On": speed = 2 - elif high_mode == 'Off': + elif high_mode == "Off": speed = 1 else: speed = self.speed_idx - elif self.control_type == 'Time2': + elif self.control_type == "Time2": # Old time-based 2-speed HVAC control - if self.mode == 'Off': + if self.mode == "Off": speed = 1 else: speed = 2 else: - raise OCHREException('Unknown control type for {}: {}'.format(self.name, self.control_type)) + raise OCHREException( + "Unknown control type for {}: {}".format(self.name, self.control_type) + ) # Enforce minimum on times for speed if self.time_in_speed < self.min_time_in_speed[prev_speed_idx - 1]: @@ -865,9 +1144,9 @@ def run_two_speed_control(self): # enforce speed disabling from external control if self.disable_speeds[speed - 1]: # set to highest allowed speed - speed = np.nonzero(~ self.disable_speeds)[0][-1] + 1 + speed = np.nonzero(~self.disable_speeds)[0][-1] + 1 - if speed != prev_speed_idx or self.mode == 'Off': + if speed != prev_speed_idx or self.mode == "Off": self.time_in_speed = self.time_res else: self.time_in_speed += self.time_res @@ -876,7 +1155,9 @@ def run_two_speed_control(self): def run_thermostat_control(self, setpoint=None): if self.use_ideal_capacity: - raise OCHREException('Ideal capacity equipment should not be running a thermostat control.') + raise OCHREException( + "Ideal capacity equipment should not be running a thermostat control." + ) if self.n_speeds == 1: # Run regular thermostat control @@ -884,21 +1165,25 @@ def run_thermostat_control(self, setpoint=None): elif self.n_speeds == 2: return self.run_two_speed_control() else: - raise OCHREException('Incompatible number of speeds for dynamic equipment:', self.n_speeds) - - def calculate_biquadratic_param(self, param, speed_idx, flow_fraction=1, part_load_ratio=1): + raise OCHREException( + "Incompatible number of speeds for dynamic equipment:", self.n_speeds + ) + + def calculate_biquadratic_param( + self, param, speed_idx, flow_fraction=1, part_load_ratio=1 + ): # runs biquadratic equation for EIR or capacity given the speed index # param is 'cap' or 'eir' # get rated value based on speed - if param == 'cap': + if param == "cap": rated = self.capacity_list[speed_idx] if self.cap_t is None: return rated else: curve_t = self.cap_t[speed_idx] curve_ff = self.cap_ff[speed_idx] - elif param == 'eir': + elif param == "eir": rated = self.eir_list[speed_idx] if self.eir_t is None: return rated @@ -907,21 +1192,21 @@ def calculate_biquadratic_param(self, param, speed_idx, flow_fraction=1, part_lo curve_ff = self.eir_ff[speed_idx] curve_plr = self.eir_plr[speed_idx] else: - raise OCHREException('Unknown biquadratic parameter:', param) + raise OCHREException("Unknown biquadratic parameter:", param) if speed_idx == 0: return rated # use coil input wet bulb for cooling, dry bulb for heating; ambient dry bulb for both t_in = self.coil_input_db if self.is_heater else self.coil_input_wb - t_ext_db = self.current_schedule['Ambient Dry Bulb (C)'] + t_ext_db = self.current_schedule["Ambient Dry Bulb (C)"] # create vectors based on temperature, flow fraction, and plr t_ratio = curve_t.evaluate(t_in, t_ext_db) ff_ratio = curve_ff.evaluate(flow_fraction) plf_ratio = 1.0 - if param == 'eir': + if param == "eir": plf_ratio = curve_plr.evaluate(part_load_ratio) return rated * t_ratio * ff_ratio / plf_ratio @@ -949,13 +1234,17 @@ def calc_startup_capacity_degredation(self): def update_capacity(self): # update max capacity using highest enabled speed - max_speed = np.nonzero(~ self.disable_speeds)[0][-1] + 1 - self.capacity_max = self.calculate_biquadratic_param(param='cap', speed_idx=max_speed) + max_speed = np.nonzero(~self.disable_speeds)[0][-1] + 1 + self.capacity_max = self.calculate_biquadratic_param( + param="cap", speed_idx=max_speed + ) if self.use_ideal_capacity: # determine capacity for each speed, check that capacity_ratio increases with speed - capacities = [self.calculate_biquadratic_param(param='cap', speed_idx=speed) - for speed in range(self.n_speeds + 1)] + capacities = [ + self.calculate_biquadratic_param(param="cap", speed_idx=speed) + for speed in range(self.n_speeds + 1) + ] assert (np.diff(capacities) >= 0).all() # determine ideal capacity @@ -973,56 +1262,70 @@ def update_capacity(self): speed_high = np.searchsorted(capacities, capacity) assert 1 <= speed_high <= self.n_speeds speed_low = speed_high - 1 - frac_high = (capacity - capacities[speed_low]) / (capacities[speed_high] - capacities[speed_low]) + frac_high = (capacity - capacities[speed_low]) / ( + capacities[speed_high] - capacities[speed_low] + ) self.speed_idx = speed_low + frac_high return capacity else: # Update capacity using biquadratic model. speed_idx should already be set - capacity = self.calculate_biquadratic_param(param='cap', speed_idx=self.speed_idx) - #update capacity for any startup degredation + capacity = self.calculate_biquadratic_param( + param="cap", speed_idx=self.speed_idx + ) + # update capacity for any startup degredation self.startup_cap_mult = self.calc_startup_capacity_degredation() capacity *= self.startup_cap_mult return capacity def update_eir(self): # Update eir and eir_max using biquadratic model - max_speed = np.nonzero(~ self.disable_speeds)[0][-1] + 1 - self.eir_max = self.calculate_biquadratic_param(param='eir', speed_idx=max_speed) + max_speed = np.nonzero(~self.disable_speeds)[0][-1] + 1 + self.eir_max = self.calculate_biquadratic_param( + param="eir", speed_idx=max_speed + ) if isinstance(self.speed_idx, int): - eir = self.calculate_biquadratic_param(param='eir', speed_idx=self.speed_idx) * (1/self.startup_cap_mult) + eir = self.calculate_biquadratic_param( + param="eir", speed_idx=self.speed_idx + ) * (1 / self.startup_cap_mult) return eir elif self.speed_idx < 1: # capacity is below lowest rated capacity, run at lowest speed with part load ratio - eir = self.calculate_biquadratic_param(param='eir', speed_idx=1, part_load_ratio=self.speed_idx) * (1/self.startup_cap_mult) + eir = self.calculate_biquadratic_param( + param="eir", speed_idx=1, part_load_ratio=self.speed_idx + ) * (1 / self.startup_cap_mult) return eir else: # interpolate between the 2 closest speeds to get EIR speed_low = int(self.speed_idx // 1) frac_high = self.speed_idx % 1 - eir_low = self.calculate_biquadratic_param(param='eir', speed_idx=speed_low) + eir_low = self.calculate_biquadratic_param(param="eir", speed_idx=speed_low) if frac_high: - eir_high = self.calculate_biquadratic_param(param='eir', speed_idx=speed_low + 1) + eir_high = self.calculate_biquadratic_param( + param="eir", speed_idx=speed_low + 1 + ) eir = eir_low * (1 - frac_high) + eir_high * frac_high else: eir = eir_low - eir *= (1/self.startup_cap_mult) #account for any startup losses + eir *= 1 / self.startup_cap_mult # account for any startup losses return eir class AirConditioner(DynamicHVAC, Cooler): - name = 'Air Conditioner' + name = "Air Conditioner" crankcase_kw = 0.050 # 50W crankcase for AC and ASHP - crankcase_temp = convert(55, 'degF', 'degC') + crankcase_temp = convert(55, "degF", "degC") def __init__(self, **kwargs): super().__init__(**kwargs) # Update PLF parameters for low efficiency equipment - seer = convert(1 / self.eir, 'W', 'Btu/hour') + seer = convert(1 / self.eir, "W", "Btu/hour") if self.n_speeds == 1 and seer < 13 and self.eir_plr is not None: - self.eir_plr[1].set(coefficients=np.array([0.8, 0.2, 0], dtype=float), min_y=0.7, max_y=1.0) + self.eir_plr[1].set( + coefficients=np.array([0.8, 0.2, 0], dtype=float), min_y=0.7, max_y=1.0 + ) def calculate_power_and_heat(self): super().calculate_power_and_heat() @@ -1030,44 +1333,47 @@ def calculate_power_and_heat(self): # add crankcase power when AC is off and outdoor temp is below threshold # no impact on sensible heat for now if self.crankcase_kw: - if self.mode == 'Off' and self.current_schedule['Ambient Dry Bulb (C)'] < self.crankcase_temp: + if ( + self.mode == "Off" + and self.current_schedule["Ambient Dry Bulb (C)"] < self.crankcase_temp + ): self.electric_kw += self.crankcase_kw * self.space_fraction class RoomAC(AirConditioner): - name = 'Room AC' + name = "Room AC" def __init__(self, **kwargs): - if kwargs.get('speed_type', 'Single') != 'Single': - raise OCHREException('No model for multi-speed {}'.format(self.name)) + if kwargs.get("speed_type", "Single") != "Single": + raise OCHREException("No model for multi-speed {}".format(self.name)) super().__init__(**kwargs) class ASHPCooler(AirConditioner): - name = 'ASHP Cooler' + name = "ASHP Cooler" # crankcase_kw = 0.020 # Keeping 50W crankcase for AC/ASHP class MinisplitHVAC(DynamicHVAC): def __init__(self, **kwargs): - if kwargs.get('Number of Speeds (-)') == 10: + if kwargs.get("Number of Speeds (-)") == 10: # update the number of speeds for MSHP from 10 to 4 - for speed_list in ['Capacity (W)', 'EIR (-)']: + for speed_list in ["Capacity (W)", "EIR (-)"]: values = kwargs[speed_list] kwargs[speed_list] = [values[1], values[3], values[5], values[9]] - kwargs['Number of Speeds (-)'] = 4 + kwargs["Number of Speeds (-)"] = 4 super().__init__(**kwargs) class MinisplitAHSPCooler(MinisplitHVAC, AirConditioner): - name = 'MSHP Cooler' + name = "MSHP Cooler" crankcase_kw = 0.015 - crankcase_temp = convert(32, 'degF', 'degC') + crankcase_temp = convert(32, "degF", "degC") class HeatPumpHeater(DynamicHVAC, Heater): - name = 'Heat Pump Heater' + name = "Heat Pump Heater" def __init__(self, **kwargs): super().__init__(**kwargs) @@ -1078,20 +1384,24 @@ def __init__(self, **kwargs): self.defrost_power_mult = 1 # Update PLF parameters for low efficiency equipment - hspf = convert(1 / self.eir, 'W', 'Btu/hour') + hspf = convert(1 / self.eir, "W", "Btu/hour") if self.eir_plr is not None and self.n_speeds == 1 and hspf >= 7: - self.eir_plr[1].set(coefficients=np.array([0.89, 0.11, 0], dtype=float), min_y=0.7, max_y=1.0) + self.eir_plr[1].set( + coefficients=np.array([0.89, 0.11, 0], dtype=float), + min_y=0.7, + max_y=1.0, + ) def update_capacity(self): # Update capacity if defrost is required capacity = super().update_capacity() - t_ext_db = self.current_schedule['Ambient Dry Bulb (C)'] - omega_ext = self.current_schedule['Ambient Humidity Ratio (-)'] + t_ext_db = self.current_schedule["Ambient Dry Bulb (C)"] + omega_ext = self.current_schedule["Ambient Humidity Ratio (-)"] if self.zone.humidity is not None: pres_ext = self.zone.humidity.pressure else: - pres_ext = self.current_schedule['Ambient Pressure (kPa)'] * 1000 # in Pa + pres_ext = self.current_schedule["Ambient Pressure (kPa)"] * 1000 # in Pa # Based on EnergyPlus Engineering Reference, Defrost Operation, for on demand, reverse cycle defrost # see https://bigladdersoftware.com/epx/docs/8-9/engineering-reference/variable-refrigerant-flow-heat-pumps.html#defrost-operation-201605050925 @@ -1100,12 +1410,21 @@ def update_capacity(self): # Calculate reduced capacity T_coil_out = 0.82 * t_ext_db - 8.589 # omega_ext = psychrolib.GetHumRatioFromRelHum(t_ext_db, rh_ext, pres_ext) - omega_sat_coil = psychrolib.GetHumRatioFromTWetBulb(T_coil_out, T_coil_out, pres_ext) + omega_sat_coil = psychrolib.GetHumRatioFromTWetBulb( + T_coil_out, T_coil_out, pres_ext + ) delta_omega_coil_out = max(0.000001, omega_ext - omega_sat_coil) defrost_time_frac = 1.0 / (1 + (0.01446 / delta_omega_coil_out)) defrost_capacity_mult = 0.875 * (1 - defrost_time_frac) - self.defrost_power_mult = 0.954 / 0.875 # increase in power relative to the capacity - q_defrost = 0.01 * defrost_time_frac * (7.222 - t_ext_db) * (self.capacity_max / 1.01667) + self.defrost_power_mult = ( + 0.954 / 0.875 + ) # increase in power relative to the capacity + q_defrost = ( + 0.01 + * defrost_time_frac + * (7.222 - t_ext_db) + * (self.capacity_max / 1.01667) + ) # Update actual capacity and max allowable capacity self.capacity_max = self.capacity_max * defrost_capacity_mult - q_defrost @@ -1116,7 +1435,9 @@ def update_capacity(self): # Calculate additional power and EIR defrost_eir_temp_mod_frac = 0.1528 # in kW - self.power_defrost = defrost_eir_temp_mod_frac * (capacity / 1.01667) * defrost_time_frac + self.power_defrost = ( + defrost_eir_temp_mod_frac * (capacity / 1.01667) * defrost_time_frac + ) else: self.defrost_power_mult = 0 self.power_defrost = 0 @@ -1126,7 +1447,9 @@ def update_eir(self): # Update EIR from defrost. Assumes update_capacity is already run eir = super().update_eir() if self.defrost and self.capacity > 0: - eir = (eir * self.capacity * self.defrost_power_mult + self.power_defrost) / self.capacity + eir = ( + eir * self.capacity * self.defrost_power_mult + self.power_defrost + ) / self.capacity return eir @@ -1134,8 +1457,9 @@ class ASHPHeater(HeatPumpHeater): """ Heat pump heater with a backup electric resistance element """ - name = 'ASHP Heater' - modes = ['HP On', 'HP and ER On', 'ER On', 'Off'] + + name = "ASHP Heater" + modes = ["HP On", "HP and ER On", "ER On", "Off"] optional_inputs = Heater.optional_inputs + [ "HVAC Heating ER Capacity (W)", "HVAC Heating Max ER Capacity Fraction (-)", @@ -1155,21 +1479,31 @@ def __init__(self, **kwargs): # backup element capacity and efficiency parameters self.er_capacity_rated = kwargs["Backup Capacity (W)"] - self.er_eir_rated = kwargs.get('Backup EIR (-)', 1) + self.er_eir_rated = kwargs.get("Backup EIR (-)", 1) self.er_capacity = 0 - self.er_ext_capacity = None # Option to set ER capacity directly, ideal capacity only - self.er_ext_capacity_frac = 1 # Option to limit max capacity, ideal capacity only + self.er_ext_capacity = ( + None # Option to set ER capacity directly, ideal capacity only + ) + self.er_ext_capacity_frac = ( + 1 # Option to limit max capacity, ideal capacity only + ) # backup element control parameters - self.hp_lockout_temp = kwargs.get("Heat Pump Lockout Temperature (C)", -17.78) # 0F default - self.er_lockout_temp = kwargs.get("Backup Lockout Temperature (C)", 4.44) # 40F default + self.hp_lockout_temp = kwargs.get( + "Heat Pump Lockout Temperature (C)", -17.78 + ) # 0F default + self.er_lockout_temp = kwargs.get( + "Backup Lockout Temperature (C)", 4.44 + ) # 40F default # ER setpoint offset = difference between temp_setpoint to bottom of ER deadband default = self.temp_deadband * (1.8 - self.deadband_offset) self.er_setpoint_offset = kwargs.get("Backup Setpoint Offset (C)", default) # minimum amount of time after a setpoint change that er stays off (user input) er_hard_lockout_time = kwargs.get("Backup Lockout Time (minutes)", 0) self.er_hard_lockout_time = dt.timedelta(minutes=er_hard_lockout_time) - er_soft_lockout_time = kwargs.get("Backup Soft Lockout Time (minutes)", er_hard_lockout_time) + er_soft_lockout_time = kwargs.get( + "Backup Soft Lockout Time (minutes)", er_hard_lockout_time + ) if er_soft_lockout_time < er_hard_lockout_time: self.warn( "Backup Soft Lockout Time is less than Backup Lockout Time. " @@ -1183,8 +1517,8 @@ def __init__(self, **kwargs): # Update minimum time for ER element min_er_on_time = kwargs.get("Minimum Backup On Time (minutes)", 0) - self.min_time_in_mode['HP and ER On'] = dt.timedelta(minutes=min_er_on_time) - self.min_time_in_mode['ER On'] = dt.timedelta(minutes=min_er_on_time) + self.min_time_in_mode["HP and ER On"] = dt.timedelta(minutes=min_er_on_time) + self.min_time_in_mode["ER On"] = dt.timedelta(minutes=min_er_on_time) def update_external_control(self, control_signal): # Additional options for ASHP external control signals: @@ -1202,7 +1536,9 @@ def update_external_control(self, control_signal): 'Set `use_ideal_capacity` to True or control "ER Duty Cycle".' ) if f"{self.end_use} Max ER Capacity Fraction (-)" in self.current_schedule: - self.current_schedule[f"{self.end_use} Max ER Capacity Fraction (-)"] = capacity_frac + self.current_schedule[ + f"{self.end_use} Max ER Capacity Fraction (-)" + ] = capacity_frac else: self.er_ext_capacity_frac = capacity_frac @@ -1217,20 +1553,25 @@ def update_external_control(self, control_signal): self.current_schedule[f"{self.end_use} Capacity (W)"] = capacity else: self.er_ext_capacity = capacity - + return super().update_external_control(control_signal) def parse_duty_cycles(self, control_signal): # If duty cycles exist, combine duty cycles for HP and ER modes - er_duty_cycle = control_signal.get('ER Duty Cycle', 0) - hp_duty_cycle = control_signal.get('Duty Cycle', 0) + er_duty_cycle = control_signal.get("ER Duty Cycle", 0) + hp_duty_cycle = control_signal.get("Duty Cycle", 0) if er_duty_cycle + hp_duty_cycle > 1: combo_duty_cycle = 1 - er_duty_cycle - hp_duty_cycle er_duty_cycle -= combo_duty_cycle hp_duty_cycle -= combo_duty_cycle duty_cycles = [hp_duty_cycle, combo_duty_cycle, er_duty_cycle, 0] else: - duty_cycles = [hp_duty_cycle, 0, er_duty_cycle, 1 - er_duty_cycle - hp_duty_cycle] + duty_cycles = [ + hp_duty_cycle, + 0, + er_duty_cycle, + 1 - er_duty_cycle - hp_duty_cycle, + ] assert sum(duty_cycles) == 1 return duty_cycles @@ -1249,54 +1590,58 @@ def update_internal_control(self): else: # get HP and ER modes separately hp_mode = super().update_internal_control() - hp_on = hp_mode in ['On', 'HP On'] if hp_mode is not None else 'HP' in self.mode + hp_on = ( + hp_mode in ["On", "HP On"] if hp_mode is not None else "HP" in self.mode + ) er_mode = self.run_er_thermostat_control() - er_on = er_mode == 'On' if er_mode is not None else 'ER' in self.mode + er_on = er_mode == "On" if er_mode is not None else "ER" in self.mode # Force HP off if outdoor temp is very cold - t_ext_db = self.current_schedule['Ambient Dry Bulb (C)'] + t_ext_db = self.current_schedule["Ambient Dry Bulb (C)"] if self.hp_lockout_temp is not None and t_ext_db < self.hp_lockout_temp: hp_on = False # combine HP and ER modes if er_on: if hp_on: - return 'HP and ER On' + return "HP and ER On" else: - return 'ER On' + return "ER On" else: if hp_on: - return 'HP On' + return "HP On" else: - return 'Off' + return "Off" self.hp_on_prev = hp_on def run_er_thermostat_control( - self, - # staged = False, - ): + self, + # staged = False, + ): # get indoor temperature - temp_indoor = self.zone.temperature - + temp_indoor = self.zone.temperature + # if the outdoor temp is greater than input value, turn er off - if self.current_schedule['Ambient Dry Bulb (C)'] >= self.er_lockout_temp: + if self.current_schedule["Ambient Dry Bulb (C)"] >= self.er_lockout_temp: self.er_lockout_time = dt.timedelta() self.prev_setpoint = self.temp_setpoint # self.existing_stages = 0 # no staged - return 'Off' + return "Off" # Determine if setpoint has changed recently - if self.temp_setpoint > self.prev_setpoint: # turned up the heat + if self.temp_setpoint > self.prev_setpoint: # turned up the heat if self.er_lockout_time < self.er_hard_lockout_time: - # Hard lockout not met, continue iterating + # Hard lockout not met, continue iterating # self.existing_stages = 0 # no staged self.er_lockout_time += self.time_res - return 'Off' - elif (self.er_lockout_time < self.er_soft_lockout_time) and (temp_indoor >= self.temp_indoor_prev): - # Soft lockout not met, and temperature is rising continue iterating + return "Off" + elif (self.er_lockout_time < self.er_soft_lockout_time) and ( + temp_indoor >= self.temp_indoor_prev + ): + # Soft lockout not met, and temperature is rising continue iterating # self.existing_stages = 0 # no staged self.er_lockout_time += self.time_res - return 'Off' + return "Off" else: # reset self.er_lockout_time = dt.timedelta() @@ -1305,11 +1650,11 @@ def run_er_thermostat_control( self.prev_setpoint = self.temp_setpoint self.er_lockout_time = dt.timedelta() # reset # self.existing_stages = 0 # no staged - return 'Off' + return "Off" # run thermostat control for ER element - lower the setpoint by the deadband or user input # On and off limits depend on heating vs. cooling - temp_turn_on = self.temp_setpoint - self.er_setpoint_offset + temp_turn_on = self.temp_setpoint - self.er_setpoint_offset temp_turn_off = temp_turn_on + self.temp_deadband # Determine mode @@ -1317,20 +1662,20 @@ def run_er_thermostat_control( self.prev_setpoint = self.temp_setpoint self.er_lockout_time = dt.timedelta() # reset # if staged==True: # TODO: need to edit downstream to make use of staged backup - # operating_capacity = self.staged_backup() - return 'On' + # operating_capacity = self.staged_backup() + return "On" if self.hvac_mult * (temp_indoor - temp_turn_off) > 0: self.er_lockout_time = dt.timedelta() # reset self.prev_setpoint = self.temp_setpoint # self.existing_stages = 0 # no staged - return 'Off' + return "Off" # TODO: staged backup (gradually increasing amount of capacity available) (lowest priority) - # def staged_backup(self, capacity_per_stage=5): + # def staged_backup(self, capacity_per_stage=5): # # Returns partial capacity based on amount of stages currently on/total amount of stages # # TODO: make a time interval between adding stages (5 min default), update with ecobee/other controls: # # https://support.ecobee.com/s/articles/Threshold-settings-for-ecobee-thermostats - # + # # # rounding to lowest integer #TODO: is the correct variable for er capacity? # number_stages = max(1, self.er_capacity_rated//capacity_per_stage) # if number_stages==1: @@ -1357,7 +1702,10 @@ def update_er_capacity(self, hp_capacity): else: # use total ideal capacity - calculated in HVAC.update_capacity er_capacity = self.capacity_ideal - hp_capacity - er_capacity = min(max(er_capacity, 0), self.er_capacity_rated * self.er_ext_capacity_frac) + er_capacity = min( + max(er_capacity, 0), + self.er_capacity_rated * self.er_ext_capacity_frac, + ) else: er_capacity = self.er_capacity_rated @@ -1366,10 +1714,10 @@ def update_er_capacity(self, hp_capacity): def update_capacity(self): # Get HP capacity and update ideal capacity hp_capacity = super().update_capacity() - if 'HP' not in self.mode: + if "HP" not in self.mode: hp_capacity = 0 - if 'ER' in self.mode: + if "ER" in self.mode: self.er_capacity = self.update_er_capacity(hp_capacity) else: self.er_capacity = 0 @@ -1381,8 +1729,8 @@ def update_fan_power(self, capacity): # if ER on and using ideal capacity, fan power is fixed at rated value # this will cause small changes in indoor temperature - if self.use_ideal_capacity and 'ER' in self.mode: - if 'HP' in self.mode: + if self.use_ideal_capacity and "ER" in self.mode: + if "HP" in self.mode: fixed_fan_power = self.fan_power_max else: fixed_fan_power = self.fan_power_max / 2 @@ -1395,21 +1743,23 @@ def update_fan_power(self, capacity): return fan_power def update_eir(self): - if self.mode == 'HP and ER On': + if self.mode == "HP and ER On": # EIR is a weighted average of HP and ER EIRs hp_eir = super().update_eir() hp_capacity = self.capacity - self.er_capacity - return (hp_capacity * hp_eir + self.er_capacity * self.er_eir_rated) / self.capacity - elif self.mode in ['HP On', 'Off']: + return ( + hp_capacity * hp_eir + self.er_capacity * self.er_eir_rated + ) / self.capacity + elif self.mode in ["HP On", "Off"]: return super().update_eir() - elif self.mode == 'ER On': + elif self.mode == "ER On": return self.er_eir_rated else: - raise OCHREException('Unknown mode for {}: {}'.format(self.name, self.mode)) + raise OCHREException("Unknown mode for {}: {}".format(self.name, self.mode)) def calculate_power_and_heat(self): # Update ER capacity if off - if 'On' not in self.mode: + if "On" not in self.mode: self.er_capacity = 0 super().calculate_power_and_heat() @@ -1420,8 +1770,8 @@ def generate_results(self): if self.verbosity >= 7: tot_power = self.capacity * self.eir * self.space_fraction / 1000 er_power = self.er_capacity * self.er_eir_rated * self.space_fraction / 1000 - results[f'{self.end_use} Main Power (kW)'] = tot_power - er_power - results[f'{self.end_use} ER Power (kW)'] = er_power + results[f"{self.end_use} Main Power (kW)"] = tot_power - er_power + results[f"{self.end_use} ER Power (kW)"] = er_power return results @@ -1435,7 +1785,7 @@ def update_results(self): class MinisplitAHSPHeater(MinisplitHVAC, ASHPHeater): - name = 'MSHP Heater' + name = "MSHP Heater" pan_heater_kw = 0.150 # turn on pan heater at 150W when below 0C pan_heater_temp = 0 # deg C @@ -1448,7 +1798,7 @@ def calculate_power_and_heat(self): # add pan heater power when outdoor temp < 32F # no impact on sensible heat - t_ext = self.current_schedule['Ambient Dry Bulb (C)'] + t_ext = self.current_schedule["Ambient Dry Bulb (C)"] self.pan_heater_on = self.pan_heater_kw > 0 and t_ext < self.pan_heater_temp if self.pan_heater_on: self.electric_kw += self.pan_heater_kw * self.space_fraction @@ -1460,6 +1810,7 @@ class PerformanceCurve: "cubic": 4, "biquadratic": 6, } + def __init__(self, curve_type, variable_type): self.curve_type = curve_type self.variable_type = variable_type @@ -1473,68 +1824,91 @@ def __init__(self, curve_type, variable_type): self.base_net_capacity = None self.base_net_input_power = None self.base_fan_power = None - self.interp_method_x1 = 'linear' - self.interp_method_x2 = 'linear' - self.extrapolation_x1 = 'constant' # options: constant, linear - self.extrapolation_x2 = 'constant' # options: constant, linear + self.interp_method_x1 = "linear" + self.interp_method_x2 = "linear" + self.extrapolation_x1 = "constant" # options: constant, linear + self.extrapolation_x2 = "constant" # options: constant, linear self.allow_extrapolation_x1 = True self.allow_extrapolation_x2 = True - if variable_type == 'temperature': + if variable_type == "temperature": self.min_x1 = -100.0 self.max_x1 = 100.0 self.min_x2 = -100.0 self.max_x2 = 100.0 - elif variable_type == 'fraction': + elif variable_type == "fraction": self.min_x1 = 0.0 self.max_x1 = 1.0 else: - raise OCHREException(f"Unsupported Performance Curve variable_type: {variable_type}") + raise OCHREException( + f"Unsupported Performance Curve variable_type: {variable_type}" + ) self.min_y = None self.max_y = None def _fit_outdoor_base_model(self): - if self.output_key not in ['gross_capacity', 'gross_EIR']: - raise OCHREException(f'Outdoor-base interpolation does not support output_key: {self.output_key}') + if self.output_key not in ["gross_capacity", "gross_EIR"]: + raise OCHREException( + f"Outdoor-base interpolation does not support output_key: {self.output_key}" + ) - required_fields = ['outdoor_temperature', 'net_capacity', 'net_COP', 'gross_capacity'] + required_fields = [ + "outdoor_temperature", + "net_capacity", + "net_COP", + "gross_capacity", + ] missing = [ - idx for idx, dp in enumerate(self.datapoints) + idx + for idx, dp in enumerate(self.datapoints) if any(field not in dp for field in required_fields) ] if missing: raise OCHREException( - f'Datapoints missing required net/gross fields {required_fields}. First missing index: {missing[0]}' + f"Datapoints missing required net/gross fields {required_fields}. First missing index: {missing[0]}" ) # Internal checking - if any('net_input_power' not in dp for dp in self.datapoints): - raise OCHREException('Datapoint missing net_input_power. Ensure preprocessing adds net_input_power.') + if any("net_input_power" not in dp for dp in self.datapoints): + raise OCHREException( + "Datapoint missing net_input_power. Ensure preprocessing adds net_input_power." + ) if self.hvac_mode is None: - if any('indoor_wetbulb' in dp for dp in self.datapoints): - self.hvac_mode = 'Cooling' + if any("indoor_wetbulb" in dp for dp in self.datapoints): + self.hvac_mode = "Cooling" else: - self.hvac_mode = 'Heating' + self.hvac_mode = "Heating" - rated_indoor = (utils_equipment.AIR_SOURCE_COOL_RATED_IWB - if self.hvac_mode == 'Cooling' - else utils_equipment.AIR_SOURCE_HEAT_RATED_IDB) - indoor_key = 'indoor_wetbulb' if self.hvac_mode == 'Cooling' else 'indoor_temperature' + rated_indoor = ( + utils_equipment.AIR_SOURCE_COOL_RATED_IWB + if self.hvac_mode == "Cooling" + else utils_equipment.AIR_SOURCE_HEAT_RATED_IDB + ) + indoor_key = ( + "indoor_wetbulb" if self.hvac_mode == "Cooling" else "indoor_temperature" + ) rated_rows = [ - dp for dp in self.datapoints - if indoor_key in dp and abs(float(dp[indoor_key]) - rated_indoor) < 1e-6 and 'outdoor_temperature' in dp + dp + for dp in self.datapoints + if indoor_key in dp + and abs(float(dp[indoor_key]) - rated_indoor) < 1e-6 + and "outdoor_temperature" in dp ] if len(rated_rows) < 2: - raise OCHREException('Need at least 2 rated-indoor rows to fit outdoor-base interpolation model') + raise OCHREException( + "Need at least 2 rated-indoor rows to fit outdoor-base interpolation model" + ) by_outdoor = {} for dp in rated_rows: - by_outdoor[float(dp['outdoor_temperature'])] = dp + by_outdoor[float(dp["outdoor_temperature"])] = dp outdoor_axis = np.array(sorted(by_outdoor.keys()), dtype=float) if outdoor_axis.size < 2: - raise OCHREException('Need at least 2 unique outdoor temperatures to fit outdoor-base interpolation model') + raise OCHREException( + "Need at least 2 unique outdoor temperatures to fit outdoor-base interpolation model" + ) net_capacity = [] net_input_power = [] @@ -1542,12 +1916,12 @@ def _fit_outdoor_base_model(self): for t_out in outdoor_axis: dp = by_outdoor[t_out] - net_cap = float(dp['net_capacity']) - net_power = float(dp['net_input_power']) - gross_cap = float(dp['gross_capacity']) + net_cap = float(dp["net_capacity"]) + net_power = float(dp["net_input_power"]) + gross_cap = float(dp["gross_capacity"]) if gross_cap is not None: - if self.hvac_mode == 'Cooling': + if self.hvac_mode == "Cooling": fan_power_values.append(gross_cap - net_cap) else: fan_power_values.append(net_cap - gross_cap) @@ -1556,7 +1930,9 @@ def _fit_outdoor_base_model(self): net_input_power.append(net_power) if not fan_power_values: - raise OCHREException('Unable to infer fan power from datapoints for outdoor-base interpolation model') + raise OCHREException( + "Unable to infer fan power from datapoints for outdoor-base interpolation model" + ) self.base_outdoor_axis = outdoor_axis self.base_net_capacity = np.array(net_capacity, dtype=float) @@ -1567,12 +1943,14 @@ def _fit_outdoor_base_model(self): def _fit_biquadratic_from_datapoints(self): # Step 0: Ensure we only process table lookup data for biquadratic temperature curves. - if self.curve_type != 'biquadratic' or not self.datapoints: + if self.curve_type != "biquadratic" or not self.datapoints: return # Step 1: Set/validate the rated normalization value. if self.rated_value is None or self.rated_value <= 0: - raise OCHREException('rated_value must be positive when setting datapoints for biquadratic curves') + raise OCHREException( + "rated_value must be positive when setting datapoints for biquadratic curves" + ) self._fit_outdoor_base_model() return @@ -1588,24 +1966,36 @@ def set(self, **kwargs): raise OCHREException( f"{self.curve_type} curve requires {required} coefficients" ) - if key in ["interp_method_x1", "interp_method_x2"] and value != 'linear': - raise OCHREException(f"Only linear interpolation is currently supported for {key}") - if key in ["extrapolation_x1", "extrapolation_x2"] and value not in ['constant', 'linear']: + if key in ["interp_method_x1", "interp_method_x2"] and value != "linear": + raise OCHREException( + f"Only linear interpolation is currently supported for {key}" + ) + if key in ["extrapolation_x1", "extrapolation_x2"] and value not in [ + "constant", + "linear", + ]: raise OCHREException(f"{key} must be either 'constant' or 'linear'") setattr(self, key, value) # Auto-detect outdoor-base interpolation for biquadratic curves with datapoints - if self.datapoints is not None and self.curve_type == 'biquadratic': + if self.datapoints is not None and self.curve_type == "biquadratic": self.use_outdoor_base_interpolation = True if self.datapoints is not None: self._fit_biquadratic_from_datapoints() - def _interpolate_1d(self, x_axis, y_axis, x_value, extrapolation='constant', allow_extrapolation=True): + def _interpolate_1d( + self, + x_axis, + y_axis, + x_value, + extrapolation="constant", + allow_extrapolation=True, + ): # x_axis is assumed sorted ascending if len(x_axis) == 0: - raise OCHREException('Cannot interpolate with empty axis') + raise OCHREException("Cannot interpolate with empty axis") if len(x_axis) == 1: return float(y_axis[0]) @@ -1617,12 +2007,12 @@ def _interpolate_1d(self, x_axis, y_axis, x_value, extrapolation='constant', all if not allow_extrapolation: raise OCHREException( - f'Extrapolation not allowed for value {x_value}. Valid range is [{x_min}, {x_max}]' + f"Extrapolation not allowed for value {x_value}. Valid range is [{x_min}, {x_max}]" ) - if extrapolation == 'constant': + if extrapolation == "constant": return float(y_axis[0] if x_value < x_min else y_axis[-1]) - elif extrapolation == 'linear': + elif extrapolation == "linear": if x_value < x_min: slope = (y_axis[1] - y_axis[0]) / (x_axis[1] - x_axis[0]) return float(y_axis[0] + slope * (x_value - x_axis[0])) @@ -1630,7 +2020,7 @@ def _interpolate_1d(self, x_axis, y_axis, x_value, extrapolation='constant', all slope = (y_axis[-1] - y_axis[-2]) / (x_axis[-1] - x_axis[-2]) return float(y_axis[-1] + slope * (x_value - x_axis[-1])) else: - raise OCHREException(f'Unknown extrapolation mode: {extrapolation}') + raise OCHREException(f"Unknown extrapolation mode: {extrapolation}") def _clip(self, value, low, high): if low is not None: @@ -1638,23 +2028,23 @@ def _clip(self, value, low, high): if high is not None: value = min(value, high) return value - + def evaluate(self, x1, x2=None): x1 = self._clip(x1, self.min_x1, self.max_x1) - if self.curve_type == 'quadratic': + if self.curve_type == "quadratic": if self.coefficients is None: - raise OCHREException('quadratic curve coefficients are not set') + raise OCHREException("quadratic curve coefficients are not set") a, b, c = self.coefficients y = a + b * x1 + c * x1**2 - elif self.curve_type == 'cubic': + elif self.curve_type == "cubic": if self.coefficients is None: - raise OCHREException('cubic curve coefficients are not set') + raise OCHREException("cubic curve coefficients are not set") a, b, c, d = self.coefficients y = a + b * x1 + c * x1**2 + d * x1**3 - elif self.curve_type == 'biquadratic': + elif self.curve_type == "biquadratic": if x2 is None: - raise OCHREException('biquadratic evaluation requires x1 and x2') + raise OCHREException("biquadratic evaluation requires x1 and x2") x2 = self._clip(x2, self.min_x2, self.max_x2) if self.base_outdoor_axis is not None: @@ -1673,7 +2063,7 @@ def evaluate(self, x1, x2=None): allow_extrapolation=self.allow_extrapolation_x2, ) - if self.hvac_mode == 'Cooling': + if self.hvac_mode == "Cooling": gross_capacity = net_capacity + self.base_fan_power else: gross_capacity = net_capacity - self.base_fan_power @@ -1681,33 +2071,39 @@ def evaluate(self, x1, x2=None): if gross_capacity <= 0 or gross_input_power <= 0: raise OCHREException( - f'Invalid gross values during curve evaluation: capacity={gross_capacity}, power={gross_input_power}' + f"Invalid gross values during curve evaluation: capacity={gross_capacity}, power={gross_input_power}" ) gross_eir = gross_input_power / gross_capacity - cap_corr, eir_corr = utils_equipment.get_ft_cap_eir_correction_factors(self.hvac_mode, x1, x2) + cap_corr, eir_corr = utils_equipment.get_ft_cap_eir_correction_factors( + self.hvac_mode, x1, x2 + ) - if self.output_key == 'gross_capacity': + if self.output_key == "gross_capacity": y = (gross_capacity * cap_corr) / self.rated_value - elif self.output_key == 'gross_EIR': + elif self.output_key == "gross_EIR": y = (gross_eir * eir_corr) / self.rated_value else: - raise OCHREException(f'Unsupported output_key for outdoor base interpolation: {self.output_key}') + raise OCHREException( + f"Unsupported output_key for outdoor base interpolation: {self.output_key}" + ) elif self.datapoints is not None: raise OCHREException( - 'Datapoint-based biquadratic curves must use outdoor-base interpolation with net fields; ' - 'regular-grid fallback is disabled.' + "Datapoint-based biquadratic curves must use outdoor-base interpolation with net fields; " + "regular-grid fallback is disabled." ) else: if self.coefficients is None: - raise OCHREException('biquadratic curve requires coefficients or datapoints') + raise OCHREException( + "biquadratic curve requires coefficients or datapoints" + ) a, b, c, d, e, f = self.coefficients y = a + b * x1 + c * x1**2 + d * x2 + e * x2**2 + f * x1 * x2 else: - raise OCHREException('Unknown curve type: {}'.format(self.curve_type)) + raise OCHREException("Unknown curve type: {}".format(self.curve_type)) if self.min_y is not None: y = max(y, self.min_y) if self.max_y is not None: y = min(y, self.max_y) - return y \ No newline at end of file + return y diff --git a/ochre/utils/equipment.py b/ochre/utils/equipment.py index aff982ff..0692bc54 100644 --- a/ochre/utils/equipment.py +++ b/ochre/utils/equipment.py @@ -10,125 +10,157 @@ EQUIPMENT_NAMES_BY_TYPE = { - 'HVAC Heating': { - ('ElectricResistance', 'Electricity'): 'Electric Baseboard', - ('Furnace', 'Electricity'): 'Electric Furnace', - ('WallFurnace', 'Electricity'): 'Electric Furnace', - ('FloorFurnace', 'Electricity'): 'Electric Furnace', - ('Boiler', 'Electricity'): 'Electric Boiler', - ('air-to-air', 'Electricity'): 'ASHP Heater', - ('mini-split', 'Electricity'): 'MSHP Heater', - ('Ideal Heater', 'Electricity'): 'Generic Heater', - (None, 'Electricity'): 'Generic Heater', - ('Furnace', 'Natural gas'): 'Gas Furnace', - ('WallFurnace', 'Natural gas'): 'Gas Furnace', - ('FloorFurnace', 'Natural gas'): 'Gas Furnace', - ('Boiler', 'Natural gas'): 'Gas Boiler', + "HVAC Heating": { + ("ElectricResistance", "Electricity"): "Electric Baseboard", + ("Furnace", "Electricity"): "Electric Furnace", + ("WallFurnace", "Electricity"): "Electric Furnace", + ("FloorFurnace", "Electricity"): "Electric Furnace", + ("Boiler", "Electricity"): "Electric Boiler", + ("air-to-air", "Electricity"): "ASHP Heater", + ("mini-split", "Electricity"): "MSHP Heater", + ("Ideal Heater", "Electricity"): "Generic Heater", + (None, "Electricity"): "Generic Heater", + ("Furnace", "Natural gas"): "Gas Furnace", + ("WallFurnace", "Natural gas"): "Gas Furnace", + ("FloorFurnace", "Natural gas"): "Gas Furnace", + ("Boiler", "Natural gas"): "Gas Boiler", }, - 'HVAC Cooling': { - ('central air conditioner', 'Electricity'): 'Air Conditioner', - ('room air conditioner', 'Electricity'): 'Room AC', - ('air-to-air', 'Electricity'): 'ASHP Cooler', - ('mini-split', 'Electricity'): 'MSHP Cooler', - ('Ideal Cooler', 'Electricity'): 'Generic Cooler', - (None, 'Electricity'): 'Generic Cooler', + "HVAC Cooling": { + ("central air conditioner", "Electricity"): "Air Conditioner", + ("room air conditioner", "Electricity"): "Room AC", + ("air-to-air", "Electricity"): "ASHP Cooler", + ("mini-split", "Electricity"): "MSHP Cooler", + ("Ideal Cooler", "Electricity"): "Generic Cooler", + (None, "Electricity"): "Generic Cooler", }, - 'Water Heating': { - ('storage water heater', 'Electricity'): 'Electric Resistance Water Heater', - ('instantaneous water heater', 'Electricity'): 'Tankless Water Heater', - ('heat pump water heater', 'Electricity'): 'Heat Pump Water Heater', - ('storage water heater', 'Natural gas'): 'Gas Water Heater', - ('instantaneous water heater', 'Natural gas'): 'Gas Tankless Water Heater', + "Water Heating": { + ("storage water heater", "Electricity"): "Electric Resistance Water Heater", + ("instantaneous water heater", "Electricity"): "Tankless Water Heater", + ("heat pump water heater", "Electricity"): "Heat Pump Water Heater", + ("storage water heater", "Natural gas"): "Gas Water Heater", + ("instantaneous water heater", "Natural gas"): "Gas Tankless Water Heater", }, } -AIR_SOURCE_HEAT_RATED_ODB = round(convert(47.0, "degF", "degC"), 1) # Rated outdoor drybulb for air-source systems, heating -AIR_SOURCE_HEAT_RATED_IDB = round(convert(70.0, "degF", "degC"), 1) # Rated indoor drybulb for air-source systems, heating -AIR_SOURCE_COOL_RATED_ODB = round(convert(95.0, "degF", "degC"), 1) # Rated outdoor drybulb for air-source systems, cooling -AIR_SOURCE_COOL_RATED_OWB = round(convert(75.0, "degF", "degC"), 1) # Rated outdoor wetbulb for air-source systems, cooling -AIR_SOURCE_COOL_RATED_IDB = round(convert(80.0, "degF", "degC"), 1) # Rated indoor drybulb for air-source systems, cooling -AIR_SOURCE_COOL_RATED_IWB = round(convert(67.0, "degF", "degC"), 1) # Rated indoor wetbulb for air-source systems, cooling -HPXML_SPEED_DESCRIPTION_MINIMUM = 'minimum' -HPXML_SPEED_DESCRIPTION_NOMINAL = 'nominal' -HPXML_SPEED_DESCRIPTION_MAXIMUM = 'maximum' -HPXML_HVAC_FAN_MOTOR_TYPE_BPM = 'BPM' -HPXML_HVAC_FAN_MOTOR_TYPE_PSC = 'PSC' +AIR_SOURCE_HEAT_RATED_ODB = round( + convert(47.0, "degF", "degC"), 1 +) # Rated outdoor drybulb for air-source systems, heating +AIR_SOURCE_HEAT_RATED_IDB = round( + convert(70.0, "degF", "degC"), 1 +) # Rated indoor drybulb for air-source systems, heating +AIR_SOURCE_COOL_RATED_ODB = round( + convert(95.0, "degF", "degC"), 1 +) # Rated outdoor drybulb for air-source systems, cooling +AIR_SOURCE_COOL_RATED_OWB = round( + convert(75.0, "degF", "degC"), 1 +) # Rated outdoor wetbulb for air-source systems, cooling +AIR_SOURCE_COOL_RATED_IDB = round( + convert(80.0, "degF", "degC"), 1 +) # Rated indoor drybulb for air-source systems, cooling +AIR_SOURCE_COOL_RATED_IWB = round( + convert(67.0, "degF", "degC"), 1 +) # Rated indoor wetbulb for air-source systems, cooling +HPXML_SPEED_DESCRIPTION_MINIMUM = "minimum" +HPXML_SPEED_DESCRIPTION_NOMINAL = "nominal" +HPXML_SPEED_DESCRIPTION_MAXIMUM = "maximum" +HPXML_HVAC_FAN_MOTOR_TYPE_BPM = "BPM" +HPXML_HVAC_FAN_MOTOR_TYPE_PSC = "PSC" + def get_duct_info(ducts, zones, boundaries, construction, location, **kwargs): # Get zone type from duct_zone and zone info - duct_zone = ducts['Zone'] - fnd_type = zones.get('Foundation', {}).get('Zone Type') - fnd_wall_ins = boundaries.get('Foundation Wall', {}).get('Insulation Details', 'Uninsulated') - fnd_ceil_ins = boundaries.get('Foundation Ceiling', {}).get('Boundary R Value', 0) - - if duct_zone == 'Attic': - vented = 'vented' if zones['Attic']['Vented'] else 'unvented' - radiant_barrier = '_radiant_barrier' if boundaries.get('Attic Roof', {}).get('Radiant Barrier', False) else '' - zone_type = f'attic_{vented}{radiant_barrier}' - elif duct_zone == 'Garage': - zone_type = 'garage' - elif duct_zone == 'Foundation' and fnd_type == 'Crawlspace': - vented = 'vent' if zones['Foundation']['Vented'] else 'unvent' - if fnd_wall_ins != 'Uninsulated' and fnd_ceil_ins > 5.3: - insulation = 'crawlspace_ins_floor_wall' + duct_zone = ducts["Zone"] + fnd_type = zones.get("Foundation", {}).get("Zone Type") + fnd_wall_ins = boundaries.get("Foundation Wall", {}).get( + "Insulation Details", "Uninsulated" + ) + fnd_ceil_ins = boundaries.get("Foundation Ceiling", {}).get("Boundary R Value", 0) + + if duct_zone == "Attic": + vented = "vented" if zones["Attic"]["Vented"] else "unvented" + radiant_barrier = ( + "_radiant_barrier" + if boundaries.get("Attic Roof", {}).get("Radiant Barrier", False) + else "" + ) + zone_type = f"attic_{vented}{radiant_barrier}" + elif duct_zone == "Garage": + zone_type = "garage" + elif duct_zone == "Foundation" and fnd_type == "Crawlspace": + vented = "vent" if zones["Foundation"]["Vented"] else "unvent" + if fnd_wall_ins != "Uninsulated" and fnd_ceil_ins > 5.3: + insulation = "crawlspace_ins_floor_wall" elif fnd_ceil_ins > 5.3: - insulation = 'crawlspace_ins_floor' + insulation = "crawlspace_ins_floor" else: - insulation = 'unins_crawlspace' - zone_type = f'{vented}_{insulation}' - elif duct_zone == 'Foundation' and 'Basement' in fnd_type: - if fnd_wall_ins != 'Uninsulated': - zone_type = 'basement_ins_walls' + insulation = "unins_crawlspace" + zone_type = f"{vented}_{insulation}" + elif duct_zone == "Foundation" and "Basement" in fnd_type: + if fnd_wall_ins != "Uninsulated": + zone_type = "basement_ins_walls" elif fnd_ceil_ins > 5.3: - zone_type = 'basement_ins_ceiling' + zone_type = "basement_ins_ceiling" else: - zone_type = 'unins_basement' + zone_type = "unins_basement" else: - raise OCHREException('Unknown duct location: {duct_location}') + raise OCHREException("Unknown duct location: {duct_location}") return { - 'Zone Type': zone_type, - 'House Volume (ft^3)': convert(construction['Conditioned Volume (m^3)'], 'm^3', 'ft^3'), - 'Latitude': location['latitude'], - 'Longitude': location['longitude'], - + "Zone Type": zone_type, + "House Volume (ft^3)": convert( + construction["Conditioned Volume (m^3)"], "m^3", "ft^3" + ), + "Latitude": location["latitude"], + "Longitude": location["longitude"], } -def update_equipment_properties(properties, schedule, zip_parameters_file='ZIP Parameters.csv', **kwargs): - all_equipment = properties['equipment'] +def update_equipment_properties( + properties, schedule, zip_parameters_file="ZIP Parameters.csv", **kwargs +): + all_equipment = properties["equipment"] # add location properties to PV if it exists - if 'PV' in all_equipment: - all_equipment['PV']['location'] = properties['location'] - + if "PV" in all_equipment: + all_equipment["PV"]["location"] = properties["location"] + # split heat pump equipment into heater and cooler - for heat_pump_name, short_name in [('Air Source Heat Pump', 'ASHP'), - ('Minisplit Heat Pump', 'MSHP')]: + for heat_pump_name, short_name in [ + ("Air Source Heat Pump", "ASHP"), + ("Minisplit Heat Pump", "MSHP"), + ]: if heat_pump_name in all_equipment: hvac_dict = all_equipment.pop(heat_pump_name) - all_equipment[f'{short_name} Heater'] = hvac_dict - all_equipment[f'{short_name} Cooler'] = hvac_dict + all_equipment[f"{short_name} Heater"] = hvac_dict + all_equipment[f"{short_name} Cooler"] = hvac_dict # Update HVAC/Water Heating equipment names based on type and fuel for end_use, names_by_type in EQUIPMENT_NAMES_BY_TYPE.items(): # Get equipment properties using either generic or named key or both generic = all_equipment.pop(end_use, {}) - named = {key: val for key, val in all_equipment.items() if key in names_by_type.values()} + named = { + key: val + for key, val in all_equipment.items() + if key in names_by_type.values() + } if len(named) > 1: eq_names = list(named.keys()) - raise OCHREException(f'Only 1 {end_use} equipment is allowed, but multiple were included in inputs: {eq_names}') - + raise OCHREException( + f"Only 1 {end_use} equipment is allowed, but multiple were included in inputs: {eq_names}" + ) + # Get equipment name from named dict and from generic (using name and fuel) eq_name, eq_data = list(named.items())[0] if named else (None, {}) - eq_type = generic.get('Equipment Name') - eq_fuel = generic.get('Fuel') - if eq_fuel not in ['Electricity', 'Natural gas', None]: - print(f'WARNING: Converting {eq_fuel} to natural gas for {end_use}.') - eq_fuel = 'Natural gas' + eq_type = generic.get("Equipment Name") + eq_fuel = generic.get("Fuel") + if eq_fuel not in ["Electricity", "Natural gas", None]: + print(f"WARNING: Converting {eq_fuel} to natural gas for {end_use}.") + eq_fuel = "Natural gas" generic_name = names_by_type.get((eq_type, eq_fuel)) if generic_name is None and eq_type is not None: - raise OCHREException(f'Unknown {end_use} type ({eq_type}) and fuel ({eq_fuel}) combo.') + raise OCHREException( + f"Unknown {end_use} type ({eq_type}) and fuel ({eq_fuel}) combo." + ) # compare names from generic and named dicts if generic_name is None and eq_name is None: @@ -137,131 +169,152 @@ def update_equipment_properties(properties, schedule, zip_parameters_file='ZIP P elif eq_name is None: eq_name = generic_name elif generic_name is None: - print(f'Using a {eq_name} for {end_use}, but no equipment specified in HPXML file') + print( + f"Using a {eq_name} for {end_use}, but no equipment specified in HPXML file" + ) elif generic_name != eq_name: # if generic and named names are different, print a note - print(f'Using a {eq_name} for {end_use} instead of a {generic_name}') + print(f"Using a {eq_name} for {end_use} instead of a {generic_name}") # Merge generic parameters and equipment-specific parameters (if they exist) equipment = {**generic, **eq_data} # Add setpoint parameters based on schedule - setpoints = schedule.get(f'{end_use} Setpoint (C)') + setpoints = schedule.get(f"{end_use} Setpoint (C)") if setpoints is not None: - equipment['Max Setpoint (C)'] = setpoints.max() - equipment['Min Setpoint (C)'] = setpoints.min() + equipment["Max Setpoint (C)"] = setpoints.max() + equipment["Min Setpoint (C)"] = setpoints.min() # Get additional parameters for ducts from envelope - if 'Supply Leakage (-)' in equipment.get('Ducts', {}): - equipment['Ducts'].update(get_duct_info(equipment['Ducts'], **properties)) + if "Supply Leakage (-)" in equipment.get("Ducts", {}): + equipment["Ducts"].update(get_duct_info(equipment["Ducts"], **properties)) all_equipment[eq_name] = equipment # Load ZIP properties file and add default equipment properties for scheduled equipment - df_zip = load_csv(zip_parameters_file, index_col='Equipment Name') - zip_columns = ['Zp', 'Ip', 'Pp', 'Zq', 'Iq', 'Pq', 'pf'] - zip_data = df_zip.loc[df_zip['Included in OCHRE'], zip_columns].to_dict('index') + df_zip = load_csv(zip_parameters_file, index_col="Equipment Name") + zip_columns = ["Zp", "Ip", "Pp", "Zq", "Iq", "Pq", "pf"] + zip_data = df_zip.loc[df_zip["Included in OCHRE"], zip_columns].to_dict("index") for eq_name, eq_dict in all_equipment.items(): all_equipment[eq_name] = {**zip_data.get(eq_name, {}), **eq_dict} return all_equipment -def calculate_duct_dse(hvac, ducts, climate_file='ASHRAE152_climate_data.csv', - zone_temp_file='ASHRAE152_zone_temperatures.csv', **kwargs): +def calculate_duct_dse( + hvac, + ducts, + climate_file="ASHRAE152_climate_data.csv", + zone_temp_file="ASHRAE152_zone_temperatures.csv", + **kwargs, +): # Inputs from HPXML - zone_type = ducts['Zone Type'] + zone_type = ducts["Zone Type"] # zone_type_fractions = {zone_type: 1} # FUTURE: allow for multiple zone types - latitude = ducts['Latitude'] - longitude = ducts['Longitude'] - house_volume = ducts['House Volume (ft^3)'] + latitude = ducts["Latitude"] + longitude = ducts["Longitude"] + house_volume = ducts["House Volume (ft^3)"] # num_returns = hvac_distribution['DistributionSystemType']['AirDistribution']['NumberofReturnRegisters'] - supply_nom_leakage = ducts['Supply Leakage (-)'] - supply_area = ducts['Supply Area (ft^2)'] - supply_nom_r = ducts['Supply R Value'] + supply_nom_leakage = ducts["Supply Leakage (-)"] + supply_area = ducts["Supply Area (ft^2)"] + supply_nom_r = ducts["Supply R Value"] if supply_nom_r <= 0: supply_r = 1.7 else: supply_r = 2.2438 + 0.5619 * supply_nom_r - return_nom_leakage = ducts['Return Leakage (-)'] - return_area = ducts['Return Area (ft^2)'] - return_nom_r = ducts['Return R Value'] + return_nom_leakage = ducts["Return Leakage (-)"] + return_area = ducts["Return Area (ft^2)"] + return_nom_r = ducts["Return R Value"] if return_nom_r <= 0: return_r = 1.7 else: return_r = 2.0388 + 0.7053 * return_nom_r # Inputs from HVAC - hvac_type = 'Heating' if hvac.is_heater else 'Cooling' + hvac_type = "Heating" if hvac.is_heater else "Cooling" if hvac.n_speeds == 1: capacity_low = None fan_flow_low = None - capacity = convert(hvac.capacity_list[1], 'W', 'Btu/hour') - fan_flow = convert(hvac.flow_rate_list[1], 'm^3/s', 'cubic_feet/min') + capacity = convert(hvac.capacity_list[1], "W", "Btu/hour") + fan_flow = convert(hvac.flow_rate_list[1], "m^3/s", "cubic_feet/min") elif hvac.n_speeds == 2: - capacity_low = convert(hvac.capacity_list[1], 'W', 'Btu/hour') - fan_flow_low = convert(hvac.flow_rate_list[1], 'm^3/s', 'cubic_feet/min') - capacity = convert(hvac.capacity_list[2], 'W', 'Btu/hour') - fan_flow = convert(hvac.flow_rate_list[2], 'm^3/s', 'cubic_feet/min') + capacity_low = convert(hvac.capacity_list[1], "W", "Btu/hour") + fan_flow_low = convert(hvac.flow_rate_list[1], "m^3/s", "cubic_feet/min") + capacity = convert(hvac.capacity_list[2], "W", "Btu/hour") + fan_flow = convert(hvac.flow_rate_list[2], "m^3/s", "cubic_feet/min") elif hvac.n_speeds == 4: - capacity_low = convert(hvac.capacity_list[2], 'W', 'Btu/hour') - fan_flow_low = convert(hvac.flow_rate_list[2], 'm^3/s', 'cubic_feet/min') - capacity = convert(hvac.capacity_list[4], 'W', 'Btu/hour') - fan_flow = convert(hvac.flow_rate_list[4], 'm^3/s', 'cubic_feet/min') + capacity_low = convert(hvac.capacity_list[2], "W", "Btu/hour") + fan_flow_low = convert(hvac.flow_rate_list[2], "m^3/s", "cubic_feet/min") + capacity = convert(hvac.capacity_list[4], "W", "Btu/hour") + fan_flow = convert(hvac.flow_rate_list[4], "m^3/s", "cubic_feet/min") else: - raise OCHREException(f'Unknown number of speeds for {hvac.name}: {hvac.n_speeds}') + raise OCHREException( + f"Unknown number of speeds for {hvac.name}: {hvac.n_speeds}" + ) # Other inputs ambient_temp = 68 if hvac.is_heater else 78 - duct_thermal_mass_corr = 'Sheet Metal' # Options: Sheet Metal or Flex Duct - cooling_control = 'TXV' # Options for cooling systems control: TXV or Other + duct_thermal_mass_corr = "Sheet Metal" # Options: Sheet Metal or Flex Duct + cooling_control = "TXV" # Options for cooling systems control: TXV or Other # Load climate file - df_climate = load_csv(climate_file, index_col='Index') + df_climate = load_csv(climate_file, index_col="Index") # Get data from ASHRAE152_climate_data.csv file # Calculate the great circle distance between two points on the earth (specified in decimal degrees) - lat = df_climate['Latitude'].values - longit = df_climate['Longitude'].values + lat = df_climate["Latitude"].values + longit = df_climate["Longitude"].values dlat = np.radians(lat) - np.radians(latitude) dlon = np.radians(longit) - np.radians(longitude) - a = np.sin(dlat / 2) * np.sin(dlat / 2) + np.cos(np.radians(latitude)) * np.cos(np.radians(lat)) * np.sin(dlon / 2) * np.sin(dlon / 2) + a = np.sin(dlat / 2) * np.sin(dlat / 2) + np.cos(np.radians(latitude)) * np.cos( + np.radians(lat) + ) * np.sin(dlon / 2) * np.sin(dlon / 2) c = 2 * np.arctan2(np.sqrt(a), np.sqrt(1 - a)) - df_climate['Distance'] = 6373.0 * c - location_index = df_climate['Distance'].argmin() + 1 + df_climate["Distance"] = 6373.0 * c + location_index = df_climate["Distance"].argmin() + 1 climate_data = df_climate.loc[location_index].to_dict() - heating_des_init = float(climate_data['Heating Design Temp']) # required for evaluating zone temp file - cooling_des_init = float(climate_data['Cooling Design Temp']) # required for evaluating zone temp file - heating_seas_init = float(climate_data['Heating Seasonal Temp']) # required for evaluating zone temp file - cooling_seas_init = float(climate_data['Cooling Seasonal Temp']) # required for evaluating zone temp file + heating_des_init = float( + climate_data["Heating Design Temp"] + ) # required for evaluating zone temp file + cooling_des_init = float( + climate_data["Cooling Design Temp"] + ) # required for evaluating zone temp file + heating_seas_init = float( + climate_data["Heating Seasonal Temp"] + ) # required for evaluating zone temp file + cooling_seas_init = float( + climate_data["Cooling Seasonal Temp"] + ) # required for evaluating zone temp file # des_HR = float(climate_data['Wdesign']) # des_in_HR = float(climate_data['Windesign']) - seas_HR = float(climate_data['Wseasonal']) + seas_HR = float(climate_data["Wseasonal"]) # seas_in_HR = float(climate_data['Winseasonal']) # des_enthalpy = float(climate_data['Design hout']) # des_in_enthalpy = float(climate_data['Design hin']) - seas_enthalpy = float(climate_data['Seasonal hout']) - seas_in_enthalpy = float(climate_data['Seasonal hin']) + seas_enthalpy = float(climate_data["Seasonal hout"]) + seas_in_enthalpy = float(climate_data["Seasonal hin"]) ground_temp = (heating_des_init + cooling_des_init) / 2 # Load zone temperature file - df_zone_temps = load_csv(zone_temp_file, index_col='Zone Type') + df_zone_temps = load_csv(zone_temp_file, index_col="Zone Type") zone_type_data = df_zone_temps.loc[zone_type].to_dict() for key, val in zone_type_data.items(): - if isinstance(val, str): + if isinstance(val, str): zone_type_data[key] = eval(val) # Calculate supply/return zone temp and enthalpy # des_temp = zone_type_data[f'{hvac_type.lower()}_des_temp'] # des_supply_zone_temp = des_temp - seas_temp = zone_type_data[f'{hvac_type.lower()}_seas_temp'] + seas_temp = zone_type_data[f"{hvac_type.lower()}_seas_temp"] seas_supply_zone_temp = seas_temp # des_supply_zone_enthalpy = des_supply_zone_temp * 0.24 + des_HR * (1061 + 0.444 * des_supply_zone_temp) - seas_supply_zone_enthalpy = seas_supply_zone_temp * 0.24 + seas_HR * (1061 + 0.444 * seas_supply_zone_temp) - supply_regain = zone_type_data['supply_duct_thermal_regain'] + seas_supply_zone_enthalpy = seas_supply_zone_temp * 0.24 + seas_HR * ( + 1061 + 0.444 * seas_supply_zone_temp + ) + supply_regain = zone_type_data["supply_duct_thermal_regain"] # Note: hvac_mult = -1 for cooling. Sign will flip for cooling # if des_supply_zone_temp * hvac.hvac_mult > ambient_temp * hvac.hvac_mult: @@ -269,9 +322,17 @@ def calculate_duct_dse(hvac, ducts, climate_file='ASHRAE152_climate_data.csv', # else: # des_return_zone_temp = des_supply_zone_temp if hvac.is_heater: - seas_return_zone_temp = (heating_seas_init + seas_supply_zone_temp)/2 if seas_temp > ambient_temp else seas_supply_zone_temp + seas_return_zone_temp = ( + (heating_seas_init + seas_supply_zone_temp) / 2 + if seas_temp > ambient_temp + else seas_supply_zone_temp + ) else: - seas_return_zone_temp = (cooling_seas_init + seas_supply_zone_temp)/2 if seas_temp < ambient_temp else seas_supply_zone_temp + seas_return_zone_temp = ( + (cooling_seas_init + seas_supply_zone_temp) / 2 + if seas_temp < ambient_temp + else seas_supply_zone_temp + ) # else: # TODO: not using the 3 lines above, since (heating_seas_temp == seas_supply_zone_temp) # seas_return_zone_temp = seas_supply_zone_temp @@ -283,9 +344,9 @@ def calculate_duct_dse(hvac, ducts, climate_file='ASHRAE152_climate_data.csv', seas_return_zone_enthalpy = (seas_enthalpy + seas_supply_zone_enthalpy) / 2 else: seas_return_zone_enthalpy = seas_supply_zone_enthalpy - return_regain = zone_type_data['return_duct_thermal_regain'] + return_regain = zone_type_data["return_duct_thermal_regain"] - if duct_thermal_mass_corr == 'Flex Duct': + if duct_thermal_mass_corr == "Flex Duct": fcycloss = 0.02 else: fcycloss = 0.05 @@ -311,11 +372,11 @@ def calculate_duct_dse(hvac, ducts, climate_file='ASHRAE152_climate_data.csv', Br_high = math.exp(-return_area / (60 * fan_flow * 0.075 * 0.24 * return_r)) imb_flow = abs(supply_duct_leakage - return_duct_leakage) if supply_duct_leakage > return_duct_leakage: - infil = (infil_fan_off ** 1.5 + imb_flow ** 1.5) ** 0.67 + infil = (infil_fan_off**1.5 + imb_flow**1.5) ** 0.67 elif imb_flow > infil_fan_off: infil = 0 else: - infil = (infil_fan_off ** 1.5 - imb_flow ** 1.5) ** 0.67 + infil = (infil_fan_off**1.5 - imb_flow**1.5) ** 0.67 # ---------- Low Speed ---------- if hvac.n_speeds > 1: @@ -339,13 +400,25 @@ def calculate_duct_dse(hvac, ducts, climate_file='ASHRAE152_climate_data.csv', # 1 - Br_high * ar_high) * des_return_temp_diff / dTe_high - as_high * ( # 1 - Bs_high) * des_supply_temp_diff / dTe_high if hvac.n_speeds == 1: - seas_uncorr_de = (as_high * Bs_high - - as_high * Bs_high * (1 - Br_high * ar_high) * seas_return_temp_diff / dTe_high - - as_high * (1 - Bs_high) * seas_supply_temp_diff / dTe_high) + seas_uncorr_de = ( + as_high * Bs_high + - as_high + * Bs_high + * (1 - Br_high * ar_high) + * seas_return_temp_diff + / dTe_high + - as_high * (1 - Bs_high) * seas_supply_temp_diff / dTe_high + ) else: - seas_uncorr_de = as_low * Bs_low - as_low * Bs_low * ( - 1 - Br_low * ar_low) * seas_return_temp_diff / dTe_low - as_low * ( - 1 - Bs_low) * seas_supply_temp_diff / dTe_low + seas_uncorr_de = ( + as_low * Bs_low + - as_low + * Bs_low + * (1 - Br_low * ar_low) + * seas_return_temp_diff + / dTe_low + - as_low * (1 - Bs_low) * seas_supply_temp_diff / dTe_low + ) else: # des_uncorr_de = as_high * fan_flow * 60 * 0.075 / -capacity * ( # -capacity / fan_flow / (60 * 0.075) + (1 - ar_high) * ( @@ -353,35 +426,76 @@ def calculate_duct_dse(hvac, ducts, climate_file='ASHRAE152_climate_data.csv', # Br_high - 1) * (ambient_temp - des_return_zone_temp) + 0.24 * ( # Bs_high - 1) * (55 - des_supply_zone_temp)) if hvac.n_speeds == 1: - seas_uncorr_de = as_high * fan_flow * 60 * 0.075 / -capacity * ( - -capacity / fan_flow / (0.075 * 60) + (1 - ar_high) * ( - seas_return_zone_enthalpy - seas_in_enthalpy) + 0.24 * ar_high * ( - Br_high - 1) * (ambient_temp - seas_return_zone_temp) + 0.24 * ( - Bs_high - 1) * (55 - seas_supply_zone_temp)) + seas_uncorr_de = ( + as_high + * fan_flow + * 60 + * 0.075 + / -capacity + * ( + -capacity / fan_flow / (0.075 * 60) + + (1 - ar_high) * (seas_return_zone_enthalpy - seas_in_enthalpy) + + 0.24 + * ar_high + * (Br_high - 1) + * (ambient_temp - seas_return_zone_temp) + + 0.24 * (Bs_high - 1) * (55 - seas_supply_zone_temp) + ) + ) else: - seas_uncorr_de = as_low * fan_flow_low * 60 * 0.075 / -capacity_low * ( - -capacity_low / fan_flow_low / (60 * 0.075) + (1 - ar_low) * ( - seas_return_zone_enthalpy - seas_in_enthalpy) + 0.24 * ar_low * ( - Br_low - 1) * (ambient_temp - seas_return_zone_temp) + 0.24 * ( - Bs_low - 1) * (55 - seas_supply_zone_temp)) + seas_uncorr_de = ( + as_low + * fan_flow_low + * 60 + * 0.075 + / -capacity_low + * ( + -capacity_low / fan_flow_low / (60 * 0.075) + + (1 - ar_low) * (seas_return_zone_enthalpy - seas_in_enthalpy) + + 0.24 + * ar_low + * (Br_low - 1) + * (ambient_temp - seas_return_zone_temp) + + 0.24 * (Bs_low - 1) * (55 - seas_supply_zone_temp) + ) + ) # ---------- High Speed ---------- if hvac.is_heater: # des_load_factor = 1 - (60 * 0.075 * 0.24 * (ambient_temp - heating_des_temp) * ( # infil - infil_fan_off)) / des_uncorr_de / capacity - seas_load_factor = 1 - (60 * 0.075 * 0.24 * (ambient_temp - heating_seas_init) * ( - infil - infil_fan_off)) / seas_uncorr_de / capacity + seas_load_factor = ( + 1 + - ( + 60 + * 0.075 + * 0.24 + * (ambient_temp - heating_seas_init) + * (infil - infil_fan_off) + ) + / seas_uncorr_de + / capacity + ) else: # des_load_factor = 1 - (60 * 0.075 * (infil - infil_fan_off) * ( # des_in_enthalpy - des_enthalpy)) / -capacity / des_uncorr_de - seas_load_factor = 1 - (60 * 0.075 * (infil - infil_fan_off) * ( - seas_in_enthalpy - seas_enthalpy)) / -capacity / seas_uncorr_de + seas_load_factor = ( + 1 + - ( + 60 + * 0.075 + * (infil - infil_fan_off) + * (seas_in_enthalpy - seas_enthalpy) + ) + / -capacity + / seas_uncorr_de + ) if hvac.is_heater: # des_equip_factor = 1 if hvac.n_speeds == 1: seas_equip_factor = 1 - elif hvac.name in ['ASHP Heater', 'MSHP Heater']: + elif hvac.name in ["ASHP Heater", "MSHP Heater"]: seas_equip_factor = 0.44 + 0.56 * seas_uncorr_de else: seas_equip_factor = 0.91 + 0.09 * seas_uncorr_de @@ -391,17 +505,23 @@ def calculate_duct_dse(hvac, ducts, climate_file='ASHRAE152_climate_data.csv', # else: # des_equip_factor = 0.65 + 0.35 * fan_flow / manu_fan_flow if hvac.n_speeds == 1: - if cooling_control == 'TXV': - seas_equip_factor = 1.62 - 0.62 * fan_flow / manu_fan_flow + 0.647 * math.log(fan_flow / manu_fan_flow) + if cooling_control == "TXV": + seas_equip_factor = ( + 1.62 + - 0.62 * fan_flow / manu_fan_flow + + 0.647 * math.log(fan_flow / manu_fan_flow) + ) else: seas_equip_factor = 0.65 + 0.35 * fan_flow / manu_fan_flow else: - if cooling_control == 'TXV': + if cooling_control == "TXV": seas_equip_factor = (0.82 + 0.18 * seas_uncorr_de) * ( - 1.62 - 0.62 * fan_flow / manu_fan_flow) + 0.647 * math.log(fan_flow / manu_fan_flow) + 1.62 - 0.62 * fan_flow / manu_fan_flow + ) + 0.647 * math.log(fan_flow / manu_fan_flow) else: seas_equip_factor = (0.82 + 0.18 * seas_uncorr_de) * ( - 0.65 + 0.35 * fan_flow / manu_fan_flow) + 0.65 + 0.35 * fan_flow / manu_fan_flow + ) # ---------- Low Speed ---------- (not used) # if hvac.n_speeds > 1: @@ -420,9 +540,17 @@ def calculate_duct_dse(hvac, ducts, climate_file='ASHRAE152_climate_data.csv', # des_de = (des_uncorr_de + supply_regain * (1 - des_uncorr_de) - # (supply_regain - return_regain - # Br_high * (ar_high * supply_regain - return_regain)) * des_return_temp_diff / dTe_high) - seas_de = (seas_uncorr_de + supply_regain * (1 - seas_uncorr_de) - - (supply_regain - return_regain - - Br_high * (ar_high * supply_regain - return_regain)) * seas_return_temp_diff / dTe_high) + seas_de = ( + seas_uncorr_de + + supply_regain * (1 - seas_uncorr_de) + - ( + supply_regain + - return_regain + - Br_high * (ar_high * supply_regain - return_regain) + ) + * seas_return_temp_diff + / dTe_high + ) # ---------- Distribution System Efficiency ---------- # des_dse = des_de * des_equip_factor * des_load_factor * (1 - fcycloss) @@ -431,25 +559,25 @@ def calculate_duct_dse(hvac, ducts, climate_file='ASHRAE152_climate_data.csv', # Using seasonal DSE, not design DSE dse = seas_dse if 1 < dse <= 1.1: - print(f'WARNING: {hvac_type} DSE slightly above 1.0 ({dse}). Setting to 1.0') + print(f"WARNING: {hvac_type} DSE slightly above 1.0 ({dse}). Setting to 1.0") elif not (0 < dse <= 1): - raise OCHREException(f'{hvac_type} DSE out of bounds: {dse}') + raise OCHREException(f"{hvac_type} DSE out of bounds: {dse}") elif dse < 0.4: - print(f'WARNING: Low {hvac_type} DSE: {dse}') + print(f"WARNING: Low {hvac_type} DSE: {dse}") return dse def calc_c_d(name, number_of_speeds): # Calculate coefficient of degredation (c_d) of equipment based on RESNET HERS Addendum 82 - if name == 'Room AC': + if name == "Room AC": c_d = 0.22 elif number_of_speeds in [1, 2]: c_d = 0.08 elif number_of_speeds == 3: c_d = 0.4 else: - c_d = 0.0 # Do no capacity degradation at startup, since this isn't on/off equipment + c_d = 0.0 # Do no capacity degradation at startup, since this isn't on/off equipment return c_d @@ -590,11 +718,15 @@ def iterate(x0, f0, x1, f1, x2, f2, icount, TolRel=1e-5, small=1e-9): # round-off, use linear fit mode = 2 else: - D = b ** 2 - 4.0 * a * c # calculate discriminant to check for real roots + D = ( + b**2 - 4.0 * a * c + ) # calculate discriminant to check for real roots if D < 0.0: # if no real roots, use linear fit mode = 2 else: - if D > 0.0: # if real unequal roots, use nearest root to recent guess + if ( + D > 0.0 + ): # if real unequal roots, use nearest root to recent guess x_new = (-b + math.sqrt(D)) / (2 * c) x_other = -x_new - b / c if abs(x_new - x0) > abs(x_other - x0): @@ -602,7 +734,9 @@ def iterate(x0, f0, x1, f1, x2, f2, icount, TolRel=1e-5, small=1e-9): else: # If real equal roots, use that root x_new = -b / (2 * c) - if f1 * f0 > 0 and f2 * f0 > 0: # If the previous two f(x) were the same sign as the new + if ( + f1 * f0 > 0 and f2 * f0 > 0 + ): # If the previous two f(x) were the same sign as the new if abs(f2) > abs(f1): x2 = x1 f2 = f1 @@ -641,108 +775,172 @@ def iterate(x0, f0, x1, f1, x2, f2, icount, TolRel=1e-5, small=1e-9): def calculate_mass_flow_rate(DBin, Win, P, flow): """ - Description: - ------------ - Calculate the mass flow rate at the given incoming air state (entering drybubl and wetbulb) and CFM - - Source: - ------- - - - Inputs: - ------- - Tdb float Entering Dry Bulb (degC) - Win float Entering Humidity Ratio (kg/kg) - P float Barometric pressure (kPa) - flow float Volumetric flow rate of unit (m^3/s) - Outputs: - -------- - mfr float mass flow rate (kg/s) + Description: + ------------ + Calculate the mass flow rate at the given incoming air state (entering drybubl and wetbulb) and CFM + + Source: + ------- + + + Inputs: + ------- + Tdb float Entering Dry Bulb (degC) + Win float Entering Humidity Ratio (kg/kg) + P float Barometric pressure (kPa) + flow float Volumetric flow rate of unit (m^3/s) + Outputs: + -------- + mfr float mass flow rate (kg/s) """ rho_in = psychrolib.GetMoistAirDensity(DBin, Win, P * 1000) mfr = flow * rho_in return mfr -def process_detailed_performance_data(detailed_performance_data, mode, nominal_capacity, rated_airflow, capacity_list, fan_power_per_flow_rate, fan_motor_type, is_ducted): + +def process_detailed_performance_data( + detailed_performance_data, + mode, + nominal_capacity, + rated_airflow, + capacity_list, + fan_power_per_flow_rate, + fan_motor_type, + is_ducted, +): datapoints_by_speed = {} for outtemp, speed_data in detailed_performance_data.items(): - for speed_description, datapoints in speed_data.items(): - if speed_description not in [HPXML_SPEED_DESCRIPTION_MINIMUM, HPXML_SPEED_DESCRIPTION_NOMINAL, HPXML_SPEED_DESCRIPTION_MAXIMUM]: - continue - if speed_description not in datapoints_by_speed: - datapoints_by_speed[speed_description] = [] - dp = {} - dp['outdoor_temperature'] = outtemp - dp['net_capacity'] = datapoints['capacity'] - dp['net_COP'] = datapoints['COP'] - datapoints_by_speed[speed_description].append(dp) - datapoints_by_speed = convert_datapoint_net_to_gross(datapoints_by_speed, mode, nominal_capacity, rated_airflow, capacity_list, fan_power_per_flow_rate, fan_motor_type, is_ducted) - #extrapolate_datapoints(datapoints_by_speed mode, hp_min_temp, weather_temp) + for speed_description, datapoints in speed_data.items(): + if speed_description not in [ + HPXML_SPEED_DESCRIPTION_MINIMUM, + HPXML_SPEED_DESCRIPTION_NOMINAL, + HPXML_SPEED_DESCRIPTION_MAXIMUM, + ]: + continue + if speed_description not in datapoints_by_speed: + datapoints_by_speed[speed_description] = [] + dp = {} + dp["outdoor_temperature"] = outtemp + dp["net_capacity"] = datapoints["capacity"] + dp["net_COP"] = datapoints["COP"] + datapoints_by_speed[speed_description].append(dp) + datapoints_by_speed = convert_datapoint_net_to_gross( + datapoints_by_speed, + mode, + nominal_capacity, + rated_airflow, + capacity_list, + fan_power_per_flow_rate, + fan_motor_type, + is_ducted, + ) + # extrapolate_datapoints(datapoints_by_speed mode, hp_min_temp, weather_temp) datapoints_by_speed = correct_ft_cap_eir(datapoints_by_speed, mode) return datapoints_by_speed + def calculate_biquadratic(x, y, c): if len(c) != 6: - raise OCHREException('Error: There must be 6 coefficients in a biquadratic polynomial') + raise OCHREException( + "Error: There must be 6 coefficients in a biquadratic polynomial" + ) z = c[0] + c[1] * x + c[2] * x**2 + c[3] * y + c[4] * y**2 + c[5] * y * x return z + def get_ft_cap_eir_correction_factors(mode, indoor_temp_c, outdoor_temp_c): if mode == "Cooling": rated_t_i = AIR_SOURCE_COOL_RATED_IWB # Cutler curve used for introducing indoor temperature dependence to capacity and EIR. - cap_ft_spec_ss = [3.717717741, -0.09918866, 0.000964488, 0.005887776, -0.000012808, -0.000132822] - eir_ft_spec_ss = [-3.400341169, 0.135184783, -0.001037932, -0.007852322, 0.000183438, -0.000142548] + cap_ft_spec_ss = [ + 3.717717741, + -0.09918866, + 0.000964488, + 0.005887776, + -0.000012808, + -0.000132822, + ] + eir_ft_spec_ss = [ + -3.400341169, + 0.135184783, + -0.001037932, + -0.007852322, + 0.000183438, + -0.000142548, + ] # Cooling variations held constant for Todb less than 75°F - curve_t_o = max(outdoor_temp_c, convert(75.0, 'degF', 'degC')) + curve_t_o = max(outdoor_temp_c, convert(75.0, "degF", "degC")) else: rated_t_i = AIR_SOURCE_HEAT_RATED_IDB # Cutler curve used for introducing indoor temperature dependence to capacity and EIR. - cap_ft_spec_ss = [0.568706266, -0.000747282, -0.0000103432, 0.00945408, 0.000050812, -0.00000677828] - eir_ft_spec_ss = [0.722917608, 0.003520184, 0.000143097, -0.005760341, 0.000141736, -0.000216676] + cap_ft_spec_ss = [ + 0.568706266, + -0.000747282, + -0.0000103432, + 0.00945408, + 0.000050812, + -0.00000677828, + ] + eir_ft_spec_ss = [ + 0.722917608, + 0.003520184, + 0.000143097, + -0.005760341, + 0.000141736, + -0.000216676, + ] curve_t_o = outdoor_temp_c cap_ft_curve_output = calculate_biquadratic( - convert(indoor_temp_c, 'degC', 'degF'), - convert(curve_t_o, 'degC', 'degF'), + convert(indoor_temp_c, "degC", "degF"), + convert(curve_t_o, "degC", "degF"), cap_ft_spec_ss, ) cap_ft_curve_output_rated = calculate_biquadratic( - convert(rated_t_i, 'degC', 'degF'), - convert(curve_t_o, 'degC', 'degF'), + convert(rated_t_i, "degC", "degF"), + convert(curve_t_o, "degC", "degF"), cap_ft_spec_ss, ) cap_correction_factor = cap_ft_curve_output / cap_ft_curve_output_rated eir_ft_curve_output = calculate_biquadratic( - convert(indoor_temp_c, 'degC', 'degF'), - convert(curve_t_o, 'degC', 'degF'), + convert(indoor_temp_c, "degC", "degF"), + convert(curve_t_o, "degC", "degF"), eir_ft_spec_ss, ) eir_ft_curve_output_rated = calculate_biquadratic( - convert(rated_t_i, 'degC', 'degF'), - convert(curve_t_o, 'degC', 'degF'), + convert(rated_t_i, "degC", "degF"), + convert(curve_t_o, "degC", "degF"), eir_ft_spec_ss, ) eir_correction_factor = eir_ft_curve_output / eir_ft_curve_output_rated return cap_correction_factor, eir_correction_factor + def correct_ft_cap_eir(datapoints_by_speed, mode): if mode == "Cooling": rated_t_i = AIR_SOURCE_COOL_RATED_IWB - indoor_t = [convert(57.0, 'degF', 'degC'), rated_t_i, convert(72.0, 'degF', 'degC')] + indoor_t = [ + convert(57.0, "degF", "degC"), + rated_t_i, + convert(72.0, "degF", "degC"), + ] else: rated_t_i = AIR_SOURCE_HEAT_RATED_IDB - indoor_t = [convert(60.0, 'degF', 'degC'), rated_t_i, convert(80.0, 'degF', 'degC')] + indoor_t = [ + convert(60.0, "degF", "degC"), + rated_t_i, + convert(80.0, "degF", "degC"), + ] for speed_description, datapoints in datapoints_by_speed.items(): for dp in datapoints: if mode == "Cooling": - dp['indoor_wetbulb'] = rated_t_i + dp["indoor_wetbulb"] = rated_t_i else: - dp['indoor_temperature'] = rated_t_i + dp["indoor_temperature"] = rated_t_i # table lookup output values for speed_description, datapoints in datapoints_by_speed.items(): @@ -754,108 +952,145 @@ def correct_ft_cap_eir(datapoints_by_speed, mode): for dp in datapoints: dp_new = dp.copy() if mode == "Cooling": - dp_new['indoor_wetbulb'] = t_i + dp_new["indoor_wetbulb"] = t_i else: - dp_new['indoor_temperature'] = t_i - cap_correction_factor, eir_correction_factor = get_ft_cap_eir_correction_factors( - mode, - t_i, - dp_new['outdoor_temperature'], + dp_new["indoor_temperature"] = t_i + cap_correction_factor, eir_correction_factor = ( + get_ft_cap_eir_correction_factors( + mode, + t_i, + dp_new["outdoor_temperature"], + ) ) # corrected capacity hash, with two temperature independent variables - dp_new['gross_capacity'] *= cap_correction_factor - dp_new['gross_COP'] /= eir_correction_factor + dp_new["gross_capacity"] *= cap_correction_factor + dp_new["gross_COP"] /= eir_correction_factor data_tmp.append(dp_new) array_tmp.append(data_tmp) for new_data in array_tmp: datapoints.extend(new_data) return datapoints_by_speed -def convert_datapoint_net_to_gross(datapoints_by_speed, mode, nominal_capacity, rated_airflow, capacity_list, fan_power_per_flow_rate, fan_motor_type, is_ducted): + +def convert_datapoint_net_to_gross( + datapoints_by_speed, + mode, + nominal_capacity, + rated_airflow, + capacity_list, + fan_power_per_flow_rate, + fan_motor_type, + is_ducted, +): speed_index = { HPXML_SPEED_DESCRIPTION_MINIMUM: 1, HPXML_SPEED_DESCRIPTION_NOMINAL: 2, - HPXML_SPEED_DESCRIPTION_MAXIMUM: 3 - } # add 1 to speed index to include off speed in capacity list - if convert(rated_airflow, 'm^3/s', 'cubic_feet/min') < 3: # Resort to heating if we get a HP w/ only heating - raise OCHREException(f'Rated CFM is too low ({convert(rated_airflow, "m^3/s", "cubic_feet/min")} cfm). Check if the nominal capacity and system type are correct.') + HPXML_SPEED_DESCRIPTION_MAXIMUM: 3, + } # add 1 to speed index to include off speed in capacity list + if ( + convert(rated_airflow, "m^3/s", "cubic_feet/min") < 3 + ): # Resort to heating if we get a HP w/ only heating + raise OCHREException( + f"Rated CFM is too low ({convert(rated_airflow, 'm^3/s', 'cubic_feet/min')} cfm). Check if the nominal capacity and system type are correct." + ) # data structure: datapoints_by_speed[speed_description][outtemp]['net_capacity'] = 1000 for speed_description, datapoints in datapoints_by_speed.items(): for dp in datapoints: - fan_airflow = rated_airflow * (capacity_list[speed_index[speed_description]] / nominal_capacity) - fan_ratio = fan_airflow / rated_airflow # equal to capacity ratio in this case, OS-HPXML could be different since the rated_cfm - fan_power = calculate_fan_power(fan_power_per_flow_rate * rated_airflow, fan_ratio, fan_motor_type, is_ducted) - dp['gross_capacity'], dp['gross_COP'] = convert_net_to_gross_capacity_cop(mode, dp['net_capacity'], fan_power, dp['net_COP']) - dp['net_input_power'] = dp['net_capacity'] / dp['net_COP'] # W - dp['gross_input_power'] = dp['gross_capacity'] / dp['gross_COP'] # W + fan_airflow = rated_airflow * ( + capacity_list[speed_index[speed_description]] / nominal_capacity + ) + fan_ratio = ( + fan_airflow / rated_airflow + ) # equal to capacity ratio in this case, OS-HPXML could be different since the rated_cfm + fan_power = calculate_fan_power( + fan_power_per_flow_rate * rated_airflow, + fan_ratio, + fan_motor_type, + is_ducted, + ) + dp["gross_capacity"], dp["gross_COP"] = convert_net_to_gross_capacity_cop( + mode, dp["net_capacity"], fan_power, dp["net_COP"] + ) + dp["net_input_power"] = dp["net_capacity"] / dp["net_COP"] # W + dp["gross_input_power"] = dp["gross_capacity"] / dp["gross_COP"] # W return datapoints_by_speed -def convert_net_to_gross_capacity_cop(mode, net_capacity, fan_power, net_cop = None): - if mode == 'Cooling': - gross_cap_watts = net_capacity + fan_power + +def convert_net_to_gross_capacity_cop(mode, net_capacity, fan_power, net_cop=None): + if mode == "Cooling": + gross_cap_watts = net_capacity + fan_power else: - gross_cap_watts = net_capacity - fan_power + gross_cap_watts = net_capacity - fan_power if net_cop is not None: - net_power = net_capacity / net_cop - gross_power = net_power - fan_power - gross_cop = gross_cap_watts / gross_power + net_power = net_capacity / net_cop + gross_power = net_power - fan_power + gross_cop = gross_cap_watts / gross_power gross_capacity = gross_cap_watts return gross_capacity, gross_cop + def calc_rated_airflow(net_rated_capacity, rated_cfm_per_ton, output_units): - return convert(net_rated_capacity, 'W', 'refrigeration_ton') * convert(rated_cfm_per_ton, 'cubic_feet/min', output_units) + return convert(net_rated_capacity, "W", "refrigeration_ton") * convert( + rated_cfm_per_ton, "cubic_feet/min", output_units + ) + def calculate_fan_power(max_fan_power, fan_ratio, fan_motor_type, is_ducted): if fan_motor_type is None: - # For system types that fan_motor_type is not specified, the fan_ratio should be 1 - if fan_ratio != 1.0 and max_fan_power != 0.0: - raise IOError( 'Missing fan motor type for systems where more than one speed is modeled') + # For system types that fan_motor_type is not specified, the fan_ratio should be 1 + if fan_ratio != 1.0 and max_fan_power != 0.0: + raise IOError( + "Missing fan motor type for systems where more than one speed is modeled" + ) - return max_fan_power + return max_fan_power else: - # Based on RESNET HERS Addendum 82 - if fan_motor_type == HPXML_HVAC_FAN_MOTOR_TYPE_BPM: - pow = 2.75 if is_ducted else 3 - return max_fan_power * (fan_ratio**pow) - elif fan_motor_type == HPXML_HVAC_FAN_MOTOR_TYPE_PSC: - return max_fan_power * fan_ratio * (0.3 * fan_ratio + 0.7) - + # Based on RESNET HERS Addendum 82 + if fan_motor_type == HPXML_HVAC_FAN_MOTOR_TYPE_BPM: + pow = 2.75 if is_ducted else 3 + return max_fan_power * (fan_ratio**pow) + elif fan_motor_type == HPXML_HVAC_FAN_MOTOR_TYPE_PSC: + return max_fan_power * fan_ratio * (0.3 * fan_ratio + 0.7) + + def get_rated_cfm_per_ton(system_type): - if system_type == 'Room AC': + if system_type == "Room AC": return 312 else: return 400 + def get_design_cfm_per_ton(system_type): - if system_type == 'Furnace' or system_type == 'Boiler': + if system_type == "Furnace" or system_type == "Boiler": return 240 else: return 360 + def calculate_shr(DBin, Win, P, Q, flow, Ao): """ - Description: - ------------ - Calculate the coil SHR at the given incoming air state, CFM, total capacity, and coil - Ao factor - - Source: - ------- - EnergyPlus source code - - Inputs: - ------- - Tdb float Entering Dry Bulb (degC) - Win float Entering Humidity Ratio (kg/kg) - P float Barometric pressure (kPa) - Q float Total capacity of unit (kW) - flow float Volumetric flow rate of unit (m^3/s) - Ao float Coil Ao factor (=UA/Cp - IN SI UNITS) - Outputs: - -------- - SHR float Sensible Heat Ratio - """ + Description: + ------------ + Calculate the coil SHR at the given incoming air state, CFM, total capacity, and coil + Ao factor + + Source: + ------- + EnergyPlus source code + + Inputs: + ------- + Tdb float Entering Dry Bulb (degC) + Win float Entering Humidity Ratio (kg/kg) + P float Barometric pressure (kPa) + Q float Total capacity of unit (kW) + flow float Volumetric flow rate of unit (m^3/s) + Ao float Coil Ao factor (=UA/Cp - IN SI UNITS) + Outputs: + -------- + SHR float Sensible Heat Ratio + """ mfr = calculate_mass_flow_rate(DBin, Win, P, flow) bf = math.exp(-1.0 * Ao / mfr) if mfr > 0 else 0.0 @@ -883,19 +1118,19 @@ def calculate_shr(DBin, Win, P, Q, flow, Ao): cvg = False for i in range(1, itmax + 1): - W_ADP = psychrolib.GetHumRatioFromRelHum(T_ADP, 1.0, P * 1000) # error = H_ADP - h_fT_w_SI(T_ADP, W_ADP) error = H_ADP - psychrolib.GetMoistAirEnthalpy(T_ADP, W_ADP) - T_ADP, cvg, T_ADP_1, error1, T_ADP_2, error2 = \ - iterate(T_ADP, error, T_ADP_1, error1, T_ADP_2, error2, i) + T_ADP, cvg, T_ADP_1, error1, T_ADP_2, error2 = iterate( + T_ADP, error, T_ADP_1, error1, T_ADP_2, error2, i + ) if cvg: break if not cvg: - print('Warning: Tsat_fh_P failed to converge') + print("Warning: Tsat_fh_P failed to converge") # h_Tin_Wadp = h_fT_w_SI(Tin, W_ADP) h_Tin_Wadp = psychrolib.GetMoistAirEnthalpy(DBin, W_ADP) @@ -910,27 +1145,27 @@ def calculate_shr(DBin, Win, P, Q, flow, Ao): def coil_ao_factor(DBin, Win, P, Qdot, flow, shr): """ - Description: - ------------ - Find the coil Ao factor at the given incoming air state (entering drybubl and wetbulb) and CFM, - total capacity and SHR - - - Source: - ------- - EnergyPlus source code - - Inputs: - ------- - Tdb float Entering Dry Bulb (degC) - Win float Entering Humidity Ratio (kg/kg) - P float Barometric pressure (kPa) - Qdot float Total capacity of unit (kW) - cfm float Volumetric flow rate of unit (m^3/s) - shr float Sensible heat ratio - Outputs: - -------- - Ao float Coil Ao Factor + Description: + ------------ + Find the coil Ao factor at the given incoming air state (entering drybubl and wetbulb) and CFM, + total capacity and SHR + + + Source: + ------- + EnergyPlus source code + + Inputs: + ------- + Tdb float Entering Dry Bulb (degC) + Win float Entering Humidity Ratio (kg/kg) + P float Barometric pressure (kPa) + Qdot float Total capacity of unit (kW) + cfm float Volumetric flow rate of unit (m^3/s) + shr float Sensible heat ratio + Outputs: + -------- + Ao float Coil Ao Factor """ bf = coil_bypass_factor(DBin, Win, P, Qdot, flow, shr) mfr = calculate_mass_flow_rate(DBin, Win, P, flow) @@ -942,27 +1177,27 @@ def coil_ao_factor(DBin, Win, P, Qdot, flow, shr): def coil_bypass_factor(DBin, Win, P, Qdot, flow, shr): """ - Description: - ------------ - Find the coil bypass factor at the given incoming air state (entering drybubl and wetbulb) and CFM, - total capacity and SHR - - - Source: - ------- - EnergyPlus source code - - Inputs: - ------- - Tdb float Entering Dry Bulb (degC) - Win float Entering Humidity Ratio (kg/kg) - P float Barometric pressure (kPa) - Qdot float Total capacity of unit (kW) - flow float Volumetric flow rate of unit (m^3/s) - shr float Sensible heat ratio - Outputs: - -------- - CBF float Coil Bypass Factor + Description: + ------------ + Find the coil bypass factor at the given incoming air state (entering drybubl and wetbulb) and CFM, + total capacity and SHR + + + Source: + ------- + EnergyPlus source code + + Inputs: + ------- + Tdb float Entering Dry Bulb (degC) + Win float Entering Humidity Ratio (kg/kg) + P float Barometric pressure (kPa) + Qdot float Total capacity of unit (kW) + flow float Volumetric flow rate of unit (m^3/s) + shr float Sensible heat ratio + Outputs: + -------- + CBF float Coil Bypass Factor """ mfr = calculate_mass_flow_rate(DBin, Win, P, flow) @@ -976,7 +1211,9 @@ def coil_bypass_factor(DBin, Win, P, Qdot, flow, shr): Tout = psychrolib.GetTDryBulbFromEnthalpyAndHumRatio(Hout, Wout) RH_out = psychrolib.GetRelHumFromHumRatio(Tout, Wout, P * 1000) - T_ADP = psychrolib.GetTDewPointFromHumRatio(Tout, Wout, P * 1000) # Initial guess for iteration + T_ADP = psychrolib.GetTDewPointFromHumRatio( + Tout, Wout, P * 1000 + ) # Initial guess for iteration if shr == 1: W_ADP = psychrolib.GetHumRatioFromTWetBulb(T_ADP, T_ADP, P * 1000) @@ -985,7 +1222,7 @@ def coil_bypass_factor(DBin, Win, P, Qdot, flow, shr): return max(BF, 0.01) if RH_out > 1: - print('Error: Conditions passed to CoilBypassFactor result in outlet RH > 100%') + print("Error: Conditions passed to CoilBypassFactor result in outlet RH > 100%") dT = DBin - Tout M_c = dW / dT From f4246a93cfbe80baab94501fe446ccef0b7022e1 Mon Sep 17 00:00:00 2001 From: Yueyue Zhou Date: Fri, 27 Mar 2026 16:03:19 -0600 Subject: [PATCH 14/14] ruff format --- ochre/Equipment/HVAC.py | 368 ++++++++++--------------------------- ochre/utils/equipment.py | 47 ++--- ochre/utils/hpxml.py | 378 ++++++++++++++++++--------------------- 3 files changed, 284 insertions(+), 509 deletions(-) diff --git a/ochre/Equipment/HVAC.py b/ochre/Equipment/HVAC.py index fcbb08a2..cb17a75b 100644 --- a/ochre/Equipment/HVAC.py +++ b/ochre/Equipment/HVAC.py @@ -39,9 +39,7 @@ def __init__(self, envelope_model=None, use_ideal_capacity=None, **kwargs): self.is_heater = False self.hvac_mult = -1 else: - raise OCHREException( - f'HVAC type for {self.name} Equipment must be "Heating" or "Cooling".' - ) + raise OCHREException(f'HVAC type for {self.name} Equipment must be "Heating" or "Cooling".') # Building envelope parameters - required for calculating ideal capacity # FUTURE: For now, require envelope model. In future, could use ext_model to provide all schedule values @@ -59,23 +57,15 @@ def __init__(self, envelope_model=None, use_ideal_capacity=None, **kwargs): # Nominal and max can be the same from NEEP database assert (np.diff(self.capacity_list) >= 0).all() self.capacity = self.capacity_list[self.speed_idx] - self.capacity_ideal = ( - self.capacity - ) # capacity to maintain setpoint, for ideal equipment, in W + self.capacity_ideal = self.capacity # capacity to maintain setpoint, for ideal equipment, in W self.capacity_max = self.capacity_list[-1] # varies for dynamic equipment, in W self.nominal_index = { 1: 1, # single speed equipment only has one speed, [0, capacity] 2: 2, # for two speed equipment, nominal capacity is at high speed, [0, low_capacity, high_capacity] 3: 2, # for variable speed equipment, nominal capacity is at middle speed, [0, low_capacity, middle_capacity, high_capacity] - }[ - len(self.capacity_list) - 1 - ] # subtract 1 because capacity_list includes 0 for off speed - self.capacity_nominal = self.capacity_list[ - self.nominal_index - ] # varies for dynamic equipment, in W - self.capacity_min = kwargs.get( - "Minimum Capacity (W)", 0 - ) # for ideal equipment, in W + }[len(self.capacity_list) - 1] # subtract 1 because capacity_list includes 0 for off speed + self.capacity_nominal = self.capacity_list[self.nominal_index] # varies for dynamic equipment, in W + self.capacity_min = kwargs.get("Minimum Capacity (W)", 0) # for ideal equipment, in W self.space_fraction = kwargs.get("Conditioned Space Fraction (-)", 1.0) self.delivered_heat = 0 # in W, total sensible heat gain, excluding duct losses @@ -86,9 +76,7 @@ def __init__(self, envelope_model=None, use_ideal_capacity=None, **kwargs): self.eir_list = [kwargs["EIR (-)"]] self.eir_list = [self.eir_list[0]] + self.eir_list # add lowest speed EIR as 'off' EIR self.eir = self.eir_list[self.speed_idx] - self.eir_max = self.eir_list[ - -1 - ] # eir at max capacity (not the largest EIR for multispeed equipment) + self.eir_max = self.eir_list[-1] # eir at max capacity (not the largest EIR for multispeed equipment) # SHR (sensible heat ratio), cooling only shr = kwargs.get("SHR (-)") @@ -104,24 +92,20 @@ def __init__(self, envelope_model=None, use_ideal_capacity=None, **kwargs): self.fan_motor_type = kwargs.get("Fan Motor Type") # 'PSC' or 'BPM' self.rated_cfm_per_ton = utils_equipment.get_rated_cfm_per_ton(self.name) if kwargs["Design Airflow (CFM)"] is not None: - self.design_airflow = convert( - kwargs["Design Airflow (CFM)"], "cubic_feet/min", "m^3/s" - ) # in m^3/s + self.design_airflow = convert(kwargs["Design Airflow (CFM)"], "cubic_feet/min", "m^3/s") # in m^3/s self.design_cfm_per_ton = kwargs["Design Airflow (CFM)"] / convert( self.capacity_nominal, "W", "refrigeration_ton" ) else: self.design_cfm_per_ton = utils_equipment.get_design_cfm_per_ton(self.name) self.design_airflow = convert( - self.design_cfm_per_ton - * convert(self.capacity_nominal, "W", "refrigeration_ton"), + self.design_cfm_per_ton * convert(self.capacity_nominal, "W", "refrigeration_ton"), "cubic_feet/min", "m^3/s", ) # in m^3/s self.flow_rate_list = [ - self.design_airflow * capacity / self.capacity_nominal - for capacity in self.capacity_list + self.design_airflow * capacity / self.capacity_nominal for capacity in self.capacity_list ] # in m^3/s self.rated_flow_rate = utils_equipment.calc_rated_airflow( self.capacity_nominal, self.rated_cfm_per_ton, "m^3/s" @@ -130,9 +114,7 @@ def __init__(self, envelope_model=None, use_ideal_capacity=None, **kwargs): # Fan power parameters self.fan_power_rated = kwargs["Rated Auxiliary Power (W)"] self.is_ducted = kwargs.get("Ducted") is not None - self.fan_power_per_flow_rate = ( - self.fan_power_rated / self.design_airflow - ) # in W per m^3/s + self.fan_power_per_flow_rate = self.fan_power_rated / self.design_airflow # in W per m^3/s self.fan_power_max = self.fan_power_per_flow_rate * self.flow_rate_list[-1] self.fan_power_list = [ utils_equipment.calculate_fan_power( @@ -144,9 +126,7 @@ def __init__(self, envelope_model=None, use_ideal_capacity=None, **kwargs): for rate in self.flow_rate_list ] # in W self.fan_power = 0 # in W - self.fan_power_ratio = self.fan_power_max / ( - self.capacity_max * self.eir_max - ) # For ideal capacity equipment + self.fan_power_ratio = self.fan_power_max / (self.capacity_max * self.eir_max) # For ideal capacity equipment initial_setpoint = kwargs["initial_schedule"][f"{self.end_use} Setpoint (C)"] self.coil_input_db = initial_setpoint # Dry bulb temperature after increase from fan power self.coil_input_wb = initial_setpoint # Wet bulb temperature after increase from fan power @@ -167,9 +147,7 @@ def __init__(self, envelope_model=None, use_ideal_capacity=None, **kwargs): self.duct_dse = 1 else: # Calculate DSE using ASHRAE 152 - self.duct_dse = utils_equipment.calculate_duct_dse( - self, ducts, **kwargs - ) + self.duct_dse = utils_equipment.calculate_duct_dse(self, ducts, **kwargs) if self.duct_dse < 1 and self.duct_zone == self.zone: self.warn(f"Ignoring duct DSE because ducts are in {self.zone.name} zone.") self.duct_dse = 1 @@ -190,13 +168,9 @@ def __init__(self, envelope_model=None, use_ideal_capacity=None, **kwargs): self.zone_fractions[self.duct_zone] = 1 - self.duct_dse if self.basement_heat_frac > 0: if basement_zone == self.duct_zone: - self.zone_fractions[basement_zone] += ( - self.duct_dse * self.basement_heat_frac - ) + self.zone_fractions[basement_zone] += self.duct_dse * self.basement_heat_frac else: - self.zone_fractions[basement_zone] = ( - self.duct_dse * self.basement_heat_frac - ) + self.zone_fractions[basement_zone] = self.duct_dse * self.basement_heat_frac # Coil Ao factor, cooling only if self.is_heater: @@ -205,9 +179,7 @@ def __init__(self, envelope_model=None, use_ideal_capacity=None, **kwargs): rated_dry_bulb = utils_equipment.AIR_SOURCE_COOL_RATED_IDB # in degrees C rated_wet_bulb = utils_equipment.AIR_SOURCE_COOL_RATED_IWB # in degrees C rated_pressure = 101.3 # in kPa - rated_w = psychrolib.GetHumRatioFromTWetBulb( - rated_dry_bulb, rated_wet_bulb, rated_pressure * 1000 - ) + rated_w = psychrolib.GetHumRatioFromTWetBulb(rated_dry_bulb, rated_wet_bulb, rated_pressure * 1000) ao_data = zip(self.capacity_list[1:], self.flow_rate_list[1:], shr_list[1:]) ao_list = [ utils_equipment.coil_ao_factor(rated_dry_bulb, rated_w, rated_pressure, capacity / 1000, flow_rate, shr) @@ -241,9 +213,7 @@ def __init__(self, envelope_model=None, use_ideal_capacity=None, **kwargs): # Use ideal or static/dynamic capacity depending on time resolution and number of speeds # 4 speeds are used for variable speed equipment, which must use ideal capacity if use_ideal_capacity is None: - use_ideal_capacity = ( - self.time_res >= dt.timedelta(minutes=5) or self.n_speeds >= 4 - ) + use_ideal_capacity = self.time_res >= dt.timedelta(minutes=5) or self.n_speeds >= 4 self.use_ideal_capacity = use_ideal_capacity def initialize_schedule(self, schedule=None, **kwargs): @@ -256,9 +226,7 @@ def initialize_schedule(self, schedule=None, **kwargs): required_inputs.append("Ambient Humidity Ratio (-)") required_inputs.append("Ambient Pressure (kPa)") - return super().initialize_schedule( - schedule, required_inputs=required_inputs, **kwargs - ) + return super().initialize_schedule(schedule, required_inputs=required_inputs, **kwargs) def update_external_control(self, control_signal): # Options for external control signals: @@ -295,9 +263,7 @@ def update_external_control(self, control_signal): 'Set `use_ideal_capacity` to True or control "Duty Cycle".' ) if f"{self.end_use} Max Capacity Fraction (-)" in self.current_schedule: - self.current_schedule[f"{self.end_use} Max Capacity Fraction (-)"] = ( - capacity_frac - ) + self.current_schedule[f"{self.end_use} Max Capacity Fraction (-)"] = capacity_frac else: self.ext_capacity_frac = capacity_frac @@ -404,12 +370,8 @@ def run_thermostat_control(self, setpoint=None): setpoint = self.temp_setpoint # On and off limits depend on heating vs. cooling - temp_turn_on = setpoint - self.hvac_mult * self.temp_deadband * ( - 1 - self.deadband_offset - ) - temp_turn_off = setpoint + self.hvac_mult * self.temp_deadband * ( - self.deadband_offset - ) + temp_turn_on = setpoint - self.hvac_mult * self.temp_deadband * (1 - self.deadband_offset) + temp_turn_off = setpoint + self.hvac_mult * self.temp_deadband * (self.deadband_offset) # Determine mode if self.hvac_mult * (self.zone.temperature - temp_turn_on) < 0: @@ -433,9 +395,7 @@ def solve_ideal_capacity(self): zone_ratios = list(self.zone_fractions.values()) # Note: h_desired should be equal to self.delivered_heat - h_desired = self.envelope_model.solve_for_inputs( - self.zone.t_idx, zone_idxs, x_desired, zone_ratios - ) # in W + h_desired = self.envelope_model.solve_for_inputs(self.zone.t_idx, zone_idxs, x_desired, zone_ratios) # in W # Account for fan power and SHR - slightly different for heating/cooling # assumes SHR and EIR from previous time step @@ -491,16 +451,12 @@ def update_shr(self): if self.fan_power_max: # calculate increased dry and wet bulb temperatures due to fan power self.coil_input_db += self.fan_power_per_flow_rate / 1000 / rho_air / cp_air - self.coil_input_wb = psychrolib.GetTWetBulbFromHumRatio( - self.coil_input_db, w_in, pres_int - ) + self.coil_input_wb = psychrolib.GetTWetBulbFromHumRatio(self.coil_input_db, w_in, pres_int) elif self.zone.humidity is not None: # Don't recalculate wet bulb if already done in humidity model self.coil_input_wb = self.zone.humidity.wet_bulb else: - self.coil_input_wb = psychrolib.GetTWetBulbFromHumRatio( - self.coil_input_db, w_in, pres_int - ) + self.coil_input_wb = psychrolib.GetTWetBulbFromHumRatio(self.coil_input_db, w_in, pres_int) # Calculate SHR based on speed speed_low = int(self.speed_idx // 1) # 0 is the lowest speed @@ -557,9 +513,7 @@ def calculate_power_and_heat(self): self.shr = self.update_shr() self.eir = self.update_eir() - heat_gain = ( - self.hvac_mult * self.capacity - ) # Heat gain in W, positive=heat, negative=cool + heat_gain = self.hvac_mult * self.capacity # Heat gain in W, positive=heat, negative=cool # Calculate total sensible and latent heat self.delivered_heat = heat_gain * self.shr + self.fan_power # SHR=1 for fan @@ -714,9 +668,7 @@ def __init__(self, **kwargs): super().__init__(**kwargs) # Boiler specific inputs - self.condensing = ( - self.eir_max < 1 / 0.9 - ) # Condensing if efficiency (AFUE) > 90% + self.condensing = self.eir_max < 1 / 0.9 # Condensing if efficiency (AFUE) > 90% if self.condensing: self.outlet_temp = 65.56 # outlet_water_temp [C] (150 F) @@ -783,27 +735,19 @@ def __init__(self, control_type="Time", **kwargs): self.n_speeds = kwargs.get("Number of Speeds (-)", 1) # 2-speed control type and timing variables self.control_type = control_type # 'Time', 'Time2', or 'Setpoint' - self.disable_speeds = np.zeros( - self.n_speeds, dtype=bool - ) # if True, disable that speed + self.disable_speeds = np.zeros(self.n_speeds, dtype=bool) # if True, disable that speed self.time_in_speed = dt.timedelta(0) min_time_in_low = kwargs.get("Minimum Low Time (minutes)", 5) min_time_in_high = kwargs.get("Minimum High Time (minutes)", 5) self.min_time_in_speed = [dt.timedelta(minutes=min_time_in_low), dt.timedelta(minutes=min_time_in_high)] self.datapoint_by_speed_htg = None self.datapoint_by_speed_clg = None - self.detailed_performance_data_htg = kwargs.get( - "HeatingDetailedPerformance", None - ) - self.detailed_performance_data_clg = kwargs.get( - "CoolingDetailedPerformance", None - ) + self.detailed_performance_data_htg = kwargs.get("HeatingDetailedPerformance", None) + self.detailed_performance_data_clg = kwargs.get("CoolingDetailedPerformance", None) # startup capacity degradation parameters self.startup_cap_mult = 1.0 # multiplier, unitless - self.c_d = kwargs.get( - "Startup Capacity Degradation (-)", 0.0 - ) # degradation factor, unitless + self.c_d = kwargs.get("Startup Capacity Degradation (-)", 0.0) # degradation factor, unitless if kwargs.get("Disable HVAC Biquadratics", False): self.eir_t = None self.eir_ff = None @@ -812,49 +756,22 @@ def __init__(self, control_type="Time", **kwargs): self.cap_ff = None else: # Adding 1 for off speed in capacity list, but biquadratic curves only apply to on speeds - self.eir_t = [ - PerformanceCurve("biquadratic", "temperature") - for _ in range(self.n_speeds + 1) - ] - self.eir_ff = [ - PerformanceCurve("quadratic", "fraction") - for _ in range(self.n_speeds + 1) - ] - self.eir_plr = [ - PerformanceCurve("quadratic", "fraction") - for _ in range(self.n_speeds + 1) - ] - self.cap_t = [ - PerformanceCurve("biquadratic", "temperature") - for _ in range(self.n_speeds + 1) - ] - self.cap_ff = [ - PerformanceCurve("quadratic", "fraction") - for _ in range(self.n_speeds + 1) - ] + self.eir_t = [PerformanceCurve("biquadratic", "temperature") for _ in range(self.n_speeds + 1)] + self.eir_ff = [PerformanceCurve("quadratic", "fraction") for _ in range(self.n_speeds + 1)] + self.eir_plr = [PerformanceCurve("quadratic", "fraction") for _ in range(self.n_speeds + 1)] + self.cap_t = [PerformanceCurve("biquadratic", "temperature") for _ in range(self.n_speeds + 1)] + self.cap_ff = [PerformanceCurve("quadratic", "fraction") for _ in range(self.n_speeds + 1)] if self.detailed_performance_data_htg: # TODO: implement detailed performance curves - rated_dp = kwargs["HeatingDetailedPerformance"][ - utils_equipment.AIR_SOURCE_HEAT_RATED_ODB - ] - kwargs["Capacity (W)"] = [ - speed_data["capacity"] for speed_data in rated_dp.values() - ] - kwargs["EIR (-)"] = [ - 1 / speed_data["COP"] for speed_data in rated_dp.values() - ] + rated_dp = kwargs["HeatingDetailedPerformance"][utils_equipment.AIR_SOURCE_HEAT_RATED_ODB] + kwargs["Capacity (W)"] = [speed_data["capacity"] for speed_data in rated_dp.values()] + kwargs["EIR (-)"] = [1 / speed_data["COP"] for speed_data in rated_dp.values()] kwargs["SHR (-)"] = [0.708] * len(kwargs["Capacity (W)"]) elif self.detailed_performance_data_clg: - rated_dp = kwargs["CoolingDetailedPerformance"][ - utils_equipment.AIR_SOURCE_COOL_RATED_ODB - ] - kwargs["Capacity (W)"] = [ - speed_data["capacity"] for speed_data in rated_dp.values() - ] - kwargs["EIR (-)"] = [ - 1 / speed_data["COP"] for speed_data in rated_dp.values() - ] + rated_dp = kwargs["CoolingDetailedPerformance"][utils_equipment.AIR_SOURCE_COOL_RATED_ODB] + kwargs["Capacity (W)"] = [speed_data["capacity"] for speed_data in rated_dp.values()] + kwargs["EIR (-)"] = [1 / speed_data["COP"] for speed_data in rated_dp.values()] kwargs["SHR (-)"] = [0.708] * len(kwargs["Capacity (W)"]) elif not kwargs.get("Disable HVAC Biquadratics", False): if self.name != "Room AC": @@ -900,17 +817,15 @@ def __init__(self, control_type="Time", **kwargs): super().__init__(**kwargs) if self.detailed_performance_data_htg: - self.datapoint_by_speed_htg = ( - utils_equipment.process_detailed_performance_data( - self.detailed_performance_data_htg, - "Heating", - self.capacity_nominal, - self.rated_flow_rate, - self.capacity_list, - self.fan_power_per_flow_rate, - self.fan_motor_type, - self.is_ducted, - ) + self.datapoint_by_speed_htg = utils_equipment.process_detailed_performance_data( + self.detailed_performance_data_htg, + "Heating", + self.capacity_nominal, + self.rated_flow_rate, + self.capacity_list, + self.fan_power_per_flow_rate, + self.fan_motor_type, + self.is_ducted, ) for index, eir_t_curve in enumerate(self.eir_t): if index == 0: @@ -931,35 +846,21 @@ def __init__(self, control_type="Time", **kwargs): hvac_mode="Heating", ) for cap_ff in self.cap_ff: - cap_ff.set( - coefficients=np.array( - [0.694045465, 0.474207981, -0.168253446], dtype=float - ) - ) + cap_ff.set(coefficients=np.array([0.694045465, 0.474207981, -0.168253446], dtype=float)) for eir_ff in self.eir_ff: - eir_ff.set( - coefficients=np.array( - [2.185418751, -1.942827919, 0.757409168], dtype=float - ) - ) + eir_ff.set(coefficients=np.array([2.185418751, -1.942827919, 0.757409168], dtype=float)) for eir_plr in self.eir_plr: - eir_plr.set( - coefficients=np.array( - [(1.0 - self.c_d), self.c_d, 0.0], dtype=float - ) - ) + eir_plr.set(coefficients=np.array([(1.0 - self.c_d), self.c_d, 0.0], dtype=float)) if self.detailed_performance_data_clg: - self.datapoint_by_speed_clg = ( - utils_equipment.process_detailed_performance_data( - self.detailed_performance_data_clg, - "Cooling", - self.capacity_nominal, - self.rated_flow_rate, - self.capacity_list, - self.fan_power_per_flow_rate, - self.fan_motor_type, - self.is_ducted, - ) + self.datapoint_by_speed_clg = utils_equipment.process_detailed_performance_data( + self.detailed_performance_data_clg, + "Cooling", + self.capacity_nominal, + self.rated_flow_rate, + self.capacity_list, + self.fan_power_per_flow_rate, + self.fan_motor_type, + self.is_ducted, ) for index, eir_t_curve in enumerate(self.eir_t): if index == 0: @@ -980,23 +881,11 @@ def __init__(self, control_type="Time", **kwargs): hvac_mode="Cooling", ) for cap_ff in self.cap_ff: - cap_ff.set( - coefficients=np.array( - [0.718664047, 0.41797409, -0.136638137], dtype=float - ) - ) + cap_ff.set(coefficients=np.array([0.718664047, 0.41797409, -0.136638137], dtype=float)) for eir_ff in self.eir_ff: - eir_ff.set( - coefficients=np.array( - [1.143487507, -0.13943972, -0.004047787], dtype=float - ) - ) + eir_ff.set(coefficients=np.array([1.143487507, -0.13943972, -0.004047787], dtype=float)) for eir_plr in self.eir_plr: - eir_plr.set( - coefficients=np.array( - [(1.0 - self.c_d), self.c_d, 0.0], dtype=float - ) - ) + eir_plr.set(coefficients=np.array([(1.0 - self.c_d), self.c_d, 0.0], dtype=float)) # Check EIR and print warning if too low if self.eir_max > 0.5: @@ -1005,9 +894,7 @@ def __init__(self, control_type="Time", **kwargs): if kwargs.get("Disable HVAC Part Load Factor", False): # for minimal tests, disable PLF for eir_plr_curve in self.eir_plr: - eir_plr_curve.set( - coefficients=np.array([1, 0, 0], dtype=float), datapoints=None - ) + eir_plr_curve.set(coefficients=np.array([1, 0, 0], dtype=float), datapoints=None) def update_external_control(self, control_signal): # Options for external control signals: @@ -1090,9 +977,7 @@ def run_thermostat_control(self, setpoint=None): else: raise OCHREException("Incompatible number of speeds for dynamic equipment:", self.n_speeds) - def calculate_biquadratic_param( - self, param, speed_idx, flow_fraction=1, part_load_ratio=1 - ): + def calculate_biquadratic_param(self, param, speed_idx, flow_fraction=1, part_load_ratio=1): # runs biquadratic equation for EIR or capacity given the speed index # param is 'cap' or 'eir' @@ -1178,9 +1063,7 @@ def update_capacity(self): speed_high = np.searchsorted(capacities, capacity) assert 1 <= speed_high <= self.n_speeds speed_low = speed_high - 1 - frac_high = (capacity - capacities[speed_low]) / ( - capacities[speed_high] - capacities[speed_low] - ) + frac_high = (capacity - capacities[speed_low]) / (capacities[speed_high] - capacities[speed_low]) self.speed_idx = speed_low + frac_high return capacity @@ -1231,9 +1114,7 @@ def __init__(self, **kwargs): # Update PLF parameters for low efficiency equipment seer = convert(1 / self.eir, "W", "Btu/hour") if self.n_speeds == 1 and seer < 13 and self.eir_plr is not None: - self.eir_plr[1].set( - coefficients=np.array([0.8, 0.2, 0], dtype=float), min_y=0.7, max_y=1.0 - ) + self.eir_plr[1].set(coefficients=np.array([0.8, 0.2, 0], dtype=float), min_y=0.7, max_y=1.0) def calculate_power_and_heat(self): super().calculate_power_and_heat() @@ -1315,21 +1196,12 @@ def update_capacity(self): # Calculate reduced capacity T_coil_out = 0.82 * t_ext_db - 8.589 # omega_ext = psychrolib.GetHumRatioFromRelHum(t_ext_db, rh_ext, pres_ext) - omega_sat_coil = psychrolib.GetHumRatioFromTWetBulb( - T_coil_out, T_coil_out, pres_ext - ) + omega_sat_coil = psychrolib.GetHumRatioFromTWetBulb(T_coil_out, T_coil_out, pres_ext) delta_omega_coil_out = max(0.000001, omega_ext - omega_sat_coil) defrost_time_frac = 1.0 / (1 + (0.01446 / delta_omega_coil_out)) defrost_capacity_mult = 0.875 * (1 - defrost_time_frac) - self.defrost_power_mult = ( - 0.954 / 0.875 - ) # increase in power relative to the capacity - q_defrost = ( - 0.01 - * defrost_time_frac - * (7.222 - t_ext_db) - * (self.capacity_max / 1.01667) - ) + self.defrost_power_mult = 0.954 / 0.875 # increase in power relative to the capacity + q_defrost = 0.01 * defrost_time_frac * (7.222 - t_ext_db) * (self.capacity_max / 1.01667) # Update actual capacity and max allowable capacity self.capacity_max = self.capacity_max * defrost_capacity_mult - q_defrost @@ -1340,9 +1212,7 @@ def update_capacity(self): # Calculate additional power and EIR defrost_eir_temp_mod_frac = 0.1528 # in kW - self.power_defrost = ( - defrost_eir_temp_mod_frac * (capacity / 1.01667) * defrost_time_frac - ) + self.power_defrost = defrost_eir_temp_mod_frac * (capacity / 1.01667) * defrost_time_frac else: self.defrost_power_mult = 0 self.power_defrost = 0 @@ -1352,9 +1222,7 @@ def update_eir(self): # Update EIR from defrost. Assumes update_capacity is already run eir = super().update_eir() if self.defrost and self.capacity > 0: - eir = ( - eir * self.capacity * self.defrost_power_mult + self.power_defrost - ) / self.capacity + eir = (eir * self.capacity * self.defrost_power_mult + self.power_defrost) / self.capacity return eir @@ -1386,29 +1254,19 @@ def __init__(self, **kwargs): self.er_capacity_rated = kwargs["Backup Capacity (W)"] self.er_eir_rated = kwargs.get("Backup EIR (-)", 1) self.er_capacity = 0 - self.er_ext_capacity = ( - None # Option to set ER capacity directly, ideal capacity only - ) - self.er_ext_capacity_frac = ( - 1 # Option to limit max capacity, ideal capacity only - ) + self.er_ext_capacity = None # Option to set ER capacity directly, ideal capacity only + self.er_ext_capacity_frac = 1 # Option to limit max capacity, ideal capacity only # backup element control parameters - self.hp_lockout_temp = kwargs.get( - "Heat Pump Lockout Temperature (C)", -17.78 - ) # 0F default - self.er_lockout_temp = kwargs.get( - "Backup Lockout Temperature (C)", 4.44 - ) # 40F default + self.hp_lockout_temp = kwargs.get("Heat Pump Lockout Temperature (C)", -17.78) # 0F default + self.er_lockout_temp = kwargs.get("Backup Lockout Temperature (C)", 4.44) # 40F default # ER setpoint offset = difference between temp_setpoint to bottom of ER deadband default = self.temp_deadband * (1.8 - self.deadband_offset) self.er_setpoint_offset = kwargs.get("Backup Setpoint Offset (C)", default) # minimum amount of time after a setpoint change that er stays off (user input) er_hard_lockout_time = kwargs.get("Backup Lockout Time (minutes)", 0) self.er_hard_lockout_time = dt.timedelta(minutes=er_hard_lockout_time) - er_soft_lockout_time = kwargs.get( - "Backup Soft Lockout Time (minutes)", er_hard_lockout_time - ) + er_soft_lockout_time = kwargs.get("Backup Soft Lockout Time (minutes)", er_hard_lockout_time) if er_soft_lockout_time < er_hard_lockout_time: self.warn( "Backup Soft Lockout Time is less than Backup Lockout Time. " @@ -1441,9 +1299,7 @@ def update_external_control(self, control_signal): 'Set `use_ideal_capacity` to True or control "ER Duty Cycle".' ) if f"{self.end_use} Max ER Capacity Fraction (-)" in self.current_schedule: - self.current_schedule[ - f"{self.end_use} Max ER Capacity Fraction (-)" - ] = capacity_frac + self.current_schedule[f"{self.end_use} Max ER Capacity Fraction (-)"] = capacity_frac else: self.er_ext_capacity_frac = capacity_frac @@ -1737,17 +1593,13 @@ def __init__(self, curve_type, variable_type): self.min_x1 = 0.0 self.max_x1 = 1.0 else: - raise OCHREException( - f"Unsupported Performance Curve variable_type: {variable_type}" - ) + raise OCHREException(f"Unsupported Performance Curve variable_type: {variable_type}") self.min_y = None self.max_y = None def _fit_outdoor_base_model(self): if self.output_key not in ["gross_capacity", "gross_EIR"]: - raise OCHREException( - f"Outdoor-base interpolation does not support output_key: {self.output_key}" - ) + raise OCHREException(f"Outdoor-base interpolation does not support output_key: {self.output_key}") required_fields = [ "outdoor_temperature", @@ -1755,11 +1607,7 @@ def _fit_outdoor_base_model(self): "net_COP", "gross_capacity", ] - missing = [ - idx - for idx, dp in enumerate(self.datapoints) - if any(field not in dp for field in required_fields) - ] + missing = [idx for idx, dp in enumerate(self.datapoints) if any(field not in dp for field in required_fields)] if missing: raise OCHREException( f"Datapoints missing required net/gross fields {required_fields}. First missing index: {missing[0]}" @@ -1767,9 +1615,7 @@ def _fit_outdoor_base_model(self): # Internal checking if any("net_input_power" not in dp for dp in self.datapoints): - raise OCHREException( - "Datapoint missing net_input_power. Ensure preprocessing adds net_input_power." - ) + raise OCHREException("Datapoint missing net_input_power. Ensure preprocessing adds net_input_power.") if self.hvac_mode is None: if any("indoor_wetbulb" in dp for dp in self.datapoints): @@ -1782,21 +1628,15 @@ def _fit_outdoor_base_model(self): if self.hvac_mode == "Cooling" else utils_equipment.AIR_SOURCE_HEAT_RATED_IDB ) - indoor_key = ( - "indoor_wetbulb" if self.hvac_mode == "Cooling" else "indoor_temperature" - ) + indoor_key = "indoor_wetbulb" if self.hvac_mode == "Cooling" else "indoor_temperature" rated_rows = [ dp for dp in self.datapoints - if indoor_key in dp - and abs(float(dp[indoor_key]) - rated_indoor) < 1e-6 - and "outdoor_temperature" in dp + if indoor_key in dp and abs(float(dp[indoor_key]) - rated_indoor) < 1e-6 and "outdoor_temperature" in dp ] if len(rated_rows) < 2: - raise OCHREException( - "Need at least 2 rated-indoor rows to fit outdoor-base interpolation model" - ) + raise OCHREException("Need at least 2 rated-indoor rows to fit outdoor-base interpolation model") by_outdoor = {} for dp in rated_rows: @@ -1804,9 +1644,7 @@ def _fit_outdoor_base_model(self): outdoor_axis = np.array(sorted(by_outdoor.keys()), dtype=float) if outdoor_axis.size < 2: - raise OCHREException( - "Need at least 2 unique outdoor temperatures to fit outdoor-base interpolation model" - ) + raise OCHREException("Need at least 2 unique outdoor temperatures to fit outdoor-base interpolation model") net_capacity = [] net_input_power = [] @@ -1828,9 +1666,7 @@ def _fit_outdoor_base_model(self): net_input_power.append(net_power) if not fan_power_values: - raise OCHREException( - "Unable to infer fan power from datapoints for outdoor-base interpolation model" - ) + raise OCHREException("Unable to infer fan power from datapoints for outdoor-base interpolation model") self.base_outdoor_axis = outdoor_axis self.base_net_capacity = np.array(net_capacity, dtype=float) @@ -1846,9 +1682,7 @@ def _fit_biquadratic_from_datapoints(self): # Step 1: Set/validate the rated normalization value. if self.rated_value is None or self.rated_value <= 0: - raise OCHREException( - "rated_value must be positive when setting datapoints for biquadratic curves" - ) + raise OCHREException("rated_value must be positive when setting datapoints for biquadratic curves") self._fit_outdoor_base_model() return @@ -1861,13 +1695,9 @@ def set(self, **kwargs): if key == "coefficients": required = self.REQUIRED_COEFFS.get(self.curve_type) if required is not None and len(value) != required: - raise OCHREException( - f"{self.curve_type} curve requires {required} coefficients" - ) + raise OCHREException(f"{self.curve_type} curve requires {required} coefficients") if key in ["interp_method_x1", "interp_method_x2"] and value != "linear": - raise OCHREException( - f"Only linear interpolation is currently supported for {key}" - ) + raise OCHREException(f"Only linear interpolation is currently supported for {key}") if key in ["extrapolation_x1", "extrapolation_x2"] and value not in [ "constant", "linear", @@ -1904,9 +1734,7 @@ def _interpolate_1d( return float(np.interp(x_value, x_axis, y_axis)) if not allow_extrapolation: - raise OCHREException( - f"Extrapolation not allowed for value {x_value}. Valid range is [{x_min}, {x_max}]" - ) + raise OCHREException(f"Extrapolation not allowed for value {x_value}. Valid range is [{x_min}, {x_max}]") if extrapolation == "constant": return float(y_axis[0] if x_value < x_min else y_axis[-1]) @@ -1973,18 +1801,14 @@ def evaluate(self, x1, x2=None): ) gross_eir = gross_input_power / gross_capacity - cap_corr, eir_corr = utils_equipment.get_ft_cap_eir_correction_factors( - self.hvac_mode, x1, x2 - ) + cap_corr, eir_corr = utils_equipment.get_ft_cap_eir_correction_factors(self.hvac_mode, x1, x2) if self.output_key == "gross_capacity": y = (gross_capacity * cap_corr) / self.rated_value elif self.output_key == "gross_EIR": y = (gross_eir * eir_corr) / self.rated_value else: - raise OCHREException( - f"Unsupported output_key for outdoor base interpolation: {self.output_key}" - ) + raise OCHREException(f"Unsupported output_key for outdoor base interpolation: {self.output_key}") elif self.datapoints is not None: raise OCHREException( "Datapoint-based biquadratic curves must use outdoor-base interpolation with net fields; " @@ -1992,9 +1816,7 @@ def evaluate(self, x1, x2=None): ) else: if self.coefficients is None: - raise OCHREException( - "biquadratic curve requires coefficients or datapoints" - ) + raise OCHREException("biquadratic curve requires coefficients or datapoints") a, b, c, d, e, f = self.coefficients y = a + b * x1 + c * x1**2 + d * x2 + e * x2**2 + f * x1 * x2 else: diff --git a/ochre/utils/equipment.py b/ochre/utils/equipment.py index 4bfc9331..d1e426b3 100644 --- a/ochre/utils/equipment.py +++ b/ochre/utils/equipment.py @@ -125,11 +125,7 @@ def update_equipment_properties(properties, schedule, zip_parameters_file="ZIP P for end_use, names_by_type in EQUIPMENT_NAMES_BY_TYPE.items(): # Get equipment properties using either generic or named key or both generic = all_equipment.pop(end_use, {}) - named = { - key: val - for key, val in all_equipment.items() - if key in names_by_type.values() - } + named = {key: val for key, val in all_equipment.items() if key in names_by_type.values()} if len(named) > 1: eq_names = list(named.keys()) raise OCHREException( @@ -647,9 +643,7 @@ def iterate(x0, f0, x1, f1, x2, f2, icount, TolRel=1e-5, small=1e-9): if D < 0.0: # if no real roots, use linear fit mode = 2 else: - if ( - D > 0.0 - ): # if real unequal roots, use nearest root to recent guess + if D > 0.0: # if real unequal roots, use nearest root to recent guess x_new = (-b + math.sqrt(D)) / (2 * c) x_other = -x_new - b / c if abs(x_new - x0) > abs(x_other - x0): @@ -657,9 +651,7 @@ def iterate(x0, f0, x1, f1, x2, f2, icount, TolRel=1e-5, small=1e-9): else: # If real equal roots, use that root x_new = -b / (2 * c) - if ( - f1 * f0 > 0 and f2 * f0 > 0 - ): # If the previous two f(x) were the same sign as the new + if f1 * f0 > 0 and f2 * f0 > 0: # If the previous two f(x) were the same sign as the new if abs(f2) > abs(f1): x2 = x1 f2 = f1 @@ -764,9 +756,7 @@ def process_detailed_performance_data( def calculate_biquadratic(x, y, c): if len(c) != 6: - raise OCHREException( - "Error: There must be 6 coefficients in a biquadratic polynomial" - ) + raise OCHREException("Error: There must be 6 coefficients in a biquadratic polynomial") z = c[0] + c[1] * x + c[2] * x**2 + c[3] * y + c[4] * y**2 + c[5] * y * x return z @@ -878,12 +868,10 @@ def correct_ft_cap_eir(datapoints_by_speed, mode): dp_new["indoor_wetbulb"] = t_i else: dp_new["indoor_temperature"] = t_i - cap_correction_factor, eir_correction_factor = ( - get_ft_cap_eir_correction_factors( - mode, - t_i, - dp_new["outdoor_temperature"], - ) + cap_correction_factor, eir_correction_factor = get_ft_cap_eir_correction_factors( + mode, + t_i, + dp_new["outdoor_temperature"], ) # corrected capacity hash, with two temperature independent variables dp_new["gross_capacity"] *= cap_correction_factor @@ -910,9 +898,7 @@ def convert_datapoint_net_to_gross( HPXML_SPEED_DESCRIPTION_NOMINAL: 2, HPXML_SPEED_DESCRIPTION_MAXIMUM: 3, } # add 1 to speed index to include off speed in capacity list - if ( - convert(rated_airflow, "m^3/s", "cubic_feet/min") < 3 - ): # Resort to heating if we get a HP w/ only heating + if convert(rated_airflow, "m^3/s", "cubic_feet/min") < 3: # Resort to heating if we get a HP w/ only heating raise OCHREException( f"Rated CFM is too low ({convert(rated_airflow, 'm^3/s', 'cubic_feet/min')} cfm). Check if the nominal capacity and system type are correct." ) @@ -920,9 +906,7 @@ def convert_datapoint_net_to_gross( # data structure: datapoints_by_speed[speed_description][outtemp]['net_capacity'] = 1000 for speed_description, datapoints in datapoints_by_speed.items(): for dp in datapoints: - fan_airflow = rated_airflow * ( - capacity_list[speed_index[speed_description]] / nominal_capacity - ) + fan_airflow = rated_airflow * (capacity_list[speed_index[speed_description]] / nominal_capacity) fan_ratio = ( fan_airflow / rated_airflow ) # equal to capacity ratio in this case, OS-HPXML could be different since the rated_cfm @@ -963,9 +947,7 @@ def calculate_fan_power(max_fan_power, fan_ratio, fan_motor_type, is_ducted): if fan_motor_type is None: # For system types that fan_motor_type is not specified, the fan_ratio should be 1 if fan_ratio != 1.0 and max_fan_power != 0.0: - raise IOError( - "Missing fan motor type for systems where more than one speed is modeled" - ) + raise IOError("Missing fan motor type for systems where more than one speed is modeled") return max_fan_power else: @@ -1045,8 +1027,7 @@ def calculate_shr(DBin, Win, P, Q, flow, Ao): # error = H_ADP - h_fT_w_SI(T_ADP, W_ADP) error = H_ADP - psychrolib.GetMoistAirEnthalpy(T_ADP, W_ADP) - T_ADP, cvg, T_ADP_1, error1, T_ADP_2, error2 = \ - iterate(T_ADP, error, T_ADP_1, error1, T_ADP_2, error2, i) + T_ADP, cvg, T_ADP_1, error1, T_ADP_2, error2 = iterate(T_ADP, error, T_ADP_1, error1, T_ADP_2, error2, i) if cvg: break @@ -1133,9 +1114,7 @@ def coil_bypass_factor(DBin, Win, P, Qdot, flow, shr): Tout = psychrolib.GetTDryBulbFromEnthalpyAndHumRatio(Hout, Wout) RH_out = psychrolib.GetRelHumFromHumRatio(Tout, Wout, P * 1000) - T_ADP = psychrolib.GetTDewPointFromHumRatio( - Tout, Wout, P * 1000 - ) # Initial guess for iteration + T_ADP = psychrolib.GetTDewPointFromHumRatio(Tout, Wout, P * 1000) # Initial guess for iteration if shr == 1: W_ADP = psychrolib.GetHumRatioFromTWetBulb(T_ADP, T_ADP, P * 1000) diff --git a/ochre/utils/hpxml.py b/ochre/utils/hpxml.py index 58783ffe..b792c8f7 100644 --- a/ochre/utils/hpxml.py +++ b/ochre/utils/hpxml.py @@ -398,9 +398,7 @@ def parse_hpxml_boundaries(hpxml, return_boundary_dicts=False, **kwargs): "Door": doors, "Garage Door": gar_doors, } - boundaries = { - key: val for key, val in boundaries.items() if len(val) - } # remove empty boundaries + boundaries = {key: val for key, val in boundaries.items() if len(val)} # remove empty boundaries if return_boundary_dicts: return boundaries, construction_dict @@ -422,9 +420,7 @@ def parse_hpxml_boundaries(hpxml, return_boundary_dicts=False, **kwargs): area for floor_option in main_floor_options for area in boundaries.get(floor_option, {}).get("Area (m^2)", []) ] if len(main_floor_areas) == 1: - first_floor_area = main_floor_areas[ - 0 - ] # area of first (lowest above grade) floor. Excludes garage + first_floor_area = main_floor_areas[0] # area of first (lowest above grade) floor. Excludes garage else: raise OCHREException(f"Unable to parse multiple floor areas: {main_floor_areas}") @@ -434,9 +430,7 @@ def parse_hpxml_boundaries(hpxml, return_boundary_dicts=False, **kwargs): area for floor_option in top_floor_options for area in boundaries.get(floor_option, {}).get("Area (m^2)", []) ] if len(top_floor_areas) == 1: - top_floor_area = top_floor_areas[ - 0 - ] # area of first (lowest above grade) floor. Excludes garage + top_floor_area = top_floor_areas[0] # area of first (lowest above grade) floor. Excludes garage else: raise OCHREException(f"Unable to parse multiple attic floor areas: {top_floor_areas}") attic_floor_area = top_floor_area + sum(boundaries.get("Garage Ceiling", {}).get("Area (m^2)", [])) @@ -484,9 +478,7 @@ def parse_hpxml_boundaries(hpxml, return_boundary_dicts=False, **kwargs): garage_floor_area = 0 garage_area_in_main = 0 - indoor_floor_area = conditioned_floor_area - first_floor_area * ( - total_floors - indoor_floors - ) + indoor_floor_area = conditioned_floor_area - first_floor_area * (total_floors - indoor_floors) indoor_floor_check = first_floor_area + top_floor_area * (indoor_floors - 1) if abs(indoor_floor_check - indoor_floor_area) > 10: print( @@ -523,9 +515,7 @@ def parse_indoor_infiltration(hpxml, construction, equipment): gas_wh = wh.get("Fuel", "Electricity") != "Electricity" and wh.get("Energy Factor (-)", 1) < 0.63 has_flue_or_chimney = gas_heater or gas_wh - return utils_envelope.calculate_ashrae_infiltration_params( - inf, construction, site, has_flue_or_chimney - ) + return utils_envelope.calculate_ashrae_infiltration_params(inf, construction, site, has_flue_or_chimney) def parse_hpxml_zones(hpxml, boundaries, construction): @@ -862,46 +852,35 @@ def get_detailed_performance_data(detailed_performance_data): raise OCHREException( "Detailed Performance Data efficiency units are not COP." ) # not sure format of this - out_temp = round(convert(float(detailed_performance_data[n]["OutdoorTemperature"]), "degF", "degC"), 1 - ) + out_temp = round(convert(float(detailed_performance_data[n]["OutdoorTemperature"]), "degF", "degC"), 1) speed = detailed_performance_data[n]["CapacityDescription"] if out_temp not in performance.keys(): performance[out_temp] = {} if speed not in performance[out_temp].keys(): performance[out_temp][speed] = {} - capacity_w = convert( - float(detailed_performance_data[n]["Capacity"]), "Btu/hour", "W" - ) + capacity_w = convert(float(detailed_performance_data[n]["Capacity"]), "Btu/hour", "W") cop = float(detailed_performance_data[n]["Efficiency"]["Value"]) - performance[out_temp][speed]['capacity'] = round(capacity_w, 2) + performance[out_temp][speed]["capacity"] = round(capacity_w, 2) - performance[out_temp][speed]['COP'] = round(cop, 2) + performance[out_temp][speed]["COP"] = round(cop, 2) return performance - def set_default_heating_detailed_performance( - number_of_speeds, hspf2, qm17full, capacity, lct - ): + def set_default_heating_detailed_performance(number_of_speeds, hspf2, qm17full, capacity, lct): # Calculates COP47full from HSPF2 using bi-linear interpolation per RESNET MINERS Addendum 82 - def interpolate_hspf2( - hspf2, qm17full, hspf2_array, qm17full_array, cop47full_array - ): + def interpolate_hspf2(hspf2, qm17full, hspf2_array, qm17full_array, cop47full_array): x1, x2 = find_array_neighbor_values(sorted(hspf2_array), hspf2) - y1, y2 = find_array_neighbor_values( - sorted(qm17full_array), qm17full - ) + y1, y2 = find_array_neighbor_values(sorted(qm17full_array), qm17full) x_indexes = [hspf2_array.index(x) for x in [x1, x2]] y_indexes = [qm17full_array.index(x) for x in [y1, y2]] fx1y1 = cop47full_array[x_indexes[0]][y_indexes[0]] fx1y2 = cop47full_array[x_indexes[0]][y_indexes[1]] fx2y1 = cop47full_array[x_indexes[1]][y_indexes[0]] fx2y2 = cop47full_array[x_indexes[1]][y_indexes[1]] - return interp4( - hspf2, qm17full, x1, x2, y1, y2, fx1y1, fx1y2, fx2y1, fx2y2 - ) - + return interp4(hspf2, qm17full, x1, x2, y1, y2, fx1y1, fx1y2, fx2y1, fx2y2) + heating_performance = {} # Datapoints to be defaulted capacity5min = None @@ -943,11 +922,13 @@ def interpolate_hspf2( eirm17full = 1.356 # (P17full/Q17full)/(P47full/Q47full) hspf2_array = [5.0, 6.5, 8.0, 9.5, 11.0] qm17full_array = [0.5, 0.533, 0.6, 0.7333, 1.0] - cop47full_array = [[1.971, 1.963, 1.946, 1.915, 1.904], - [2.844, 2.801, 2.720, 2.589, 2.498], - [3.933, 3.819, 3.622, 3.318, 3.102], - [5.327, 5.085, 4.683, 4.111, 3.718], - [7.178, 6.699, 5.951, 4.975, 4.345]] + cop47full_array = [ + [1.971, 1.963, 1.946, 1.915, 1.904], + [2.844, 2.801, 2.720, 2.589, 2.498], + [3.933, 3.819, 3.622, 3.318, 3.102], + [5.327, 5.085, 4.683, 4.111, 3.718], + [7.178, 6.699, 5.951, 4.975, 4.345], + ] cop47full = interpolate_hspf2(hspf2, qm17full, hspf2_array, qm17full_array, cop47full_array) # COPs @ 17F cop17full = cop47full / eirm17full @@ -955,21 +936,25 @@ def interpolate_hspf2( capacity5full = interp2(5.0, 17.0, 47.0, capacity17full, capacity47full) # COPs @ 5F if capacity5full > 0: - cop5full = capacity5full / interp2(5.0, 17.0, 47.0, capacity17full / cop17full, capacity47full / cop47full) + cop5full = capacity5full / interp2( + 5.0, 17.0, 47.0, capacity17full / cop17full, capacity47full / cop47full + ) else: - cop5full = interp2(5.0, 17.0, 47.0, cop17full, cop47full) # Arbitrary + cop5full = interp2(5.0, 17.0, 47.0, cop17full, cop47full) # Arbitrary elif number_of_speeds == 2: - eirm17full = 1.356 # (P17full/Q17full)/(P47full/Q47full) - qrhmin = 0.712 # Qmin/Qfull - eirrhmin = 0.850 # (Pmin/Qmin)/(Pfull/Qfull) + eirm17full = 1.356 # (P17full/Q17full)/(P47full/Q47full) + qrhmin = 0.712 # Qmin/Qfull + eirrhmin = 0.850 # (Pmin/Qmin)/(Pfull/Qfull) hspf2_array = [5.0, 6.5, 8.0, 9.5, 11.0] qm17full_array = [0.5, 0.533, 0.6, 0.7333, 1.0] - cop47full_array = [[1.794, 1.779, 1.757, 1.720, 1.659], - [2.592, 2.540, 2.456, 2.325, 2.176], - [3.583, 3.464, 3.270, 2.980, 2.703], - [4.852, 4.611, 4.227, 3.691, 3.239], - [6.536, 6.073, 5.371, 4.467, 3.785]] + cop47full_array = [ + [1.794, 1.779, 1.757, 1.720, 1.659], + [2.592, 2.540, 2.456, 2.325, 2.176], + [3.583, 3.464, 3.270, 2.980, 2.703], + [4.852, 4.611, 4.227, 3.691, 3.239], + [6.536, 6.073, 5.371, 4.467, 3.785], + ] cop47full = interpolate_hspf2(hspf2, qm17full, hspf2_array, qm17full_array, cop47full_array) # Capacities @ 47F @@ -992,41 +977,45 @@ def interpolate_hspf2( # COPs @ 5F if capacity5full > 0: - cop5full = capacity5full / interp2(5.0, 17.0, 47.0, capacity17full / cop17full, capacity47full / cop47full) + cop5full = capacity5full / interp2( + 5.0, 17.0, 47.0, capacity17full / cop17full, capacity47full / cop47full + ) else: - cop5full = interp2(5.0, 17.0, 47.0, cop17full, cop47full) # Arbitrary + cop5full = interp2(5.0, 17.0, 47.0, cop17full, cop47full) # Arbitrary if capacity5min > 0: cop5min = capacity5min / interp2(5.0, 17.0, 47.0, capacity17min / cop17min, capacity47min / cop47min) else: - cop5min = interp2(5.0, 17.0, 47.0, cop17min, cop47min) # Arbitrary + cop5min = interp2(5.0, 17.0, 47.0, cop17min, cop47min) # Arbitrary elif number_of_speeds == 3: - qr47full = 0.908 # Q47full/Q47max - qr47min = 0.272 # Q47min/Q47max - qr17full = 0.817 # Q17full/Q17max - qr17min = 0.341 # Q17min/Q17max - qm5max = 0.866 # Q5max/Q17max - qr5full = 0.988 # Q5full/Q5max - qr5min = 0.321 # Q5min/Q5max - qmslopeLCTmax = -0.025 # (1.0 - Q5max/QLCTmax)/(5 - LCT) - qmslopeLCTmin = -0.024 # (1.0 - Q5min/QLCTmin)/(5 - LCT) - eirr47full = 0.939 # (P47full/Q47full)/(P47max/Q47max) - eirr47min = 0.730 # (P47min/Q47min)/(P47max/Q47max) - eirm17full = 1.351 # (P17full/Q17full)/(P47full/Q47full) - eirr17full = 0.902 # (P17full/Q17full)/(P17max/Q17max) - eirr17min = 0.798 # (P17min/Q17min)/(P17max/Q17max) - eirm5max = 1.164 # (P5max/Q5max)/(P17max/Q17max) - eirr5full = 1.000 # (P5full/Q5full)/(P5max/Q5max) - eirr5min = 0.866 # (P5min/Q5min)/(P5max/Q5max) - eirmslopeLCTmax = 0.012 # (1.0 - (PLCTmax/QLCTmax)/(P5max/Q5max))/(5 - LCT) - eirmslopeLCTmin = 0.012 # (1.0 - (PLCTmin/QLCTmin)/(P5min/Q5min))/(5 - LCT) + qr47full = 0.908 # Q47full/Q47max + qr47min = 0.272 # Q47min/Q47max + qr17full = 0.817 # Q17full/Q17max + qr17min = 0.341 # Q17min/Q17max + qm5max = 0.866 # Q5max/Q17max + qr5full = 0.988 # Q5full/Q5max + qr5min = 0.321 # Q5min/Q5max + qmslopeLCTmax = -0.025 # (1.0 - Q5max/QLCTmax)/(5 - LCT) + qmslopeLCTmin = -0.024 # (1.0 - Q5min/QLCTmin)/(5 - LCT) + eirr47full = 0.939 # (P47full/Q47full)/(P47max/Q47max) + eirr47min = 0.730 # (P47min/Q47min)/(P47max/Q47max) + eirm17full = 1.351 # (P17full/Q17full)/(P47full/Q47full) + eirr17full = 0.902 # (P17full/Q17full)/(P17max/Q17max) + eirr17min = 0.798 # (P17min/Q17min)/(P17max/Q17max) + eirm5max = 1.164 # (P5max/Q5max)/(P17max/Q17max) + eirr5full = 1.000 # (P5full/Q5full)/(P5max/Q5max) + eirr5min = 0.866 # (P5min/Q5min)/(P5max/Q5max) + eirmslopeLCTmax = 0.012 # (1.0 - (PLCTmax/QLCTmax)/(P5max/Q5max))/(5 - LCT) + eirmslopeLCTmin = 0.012 # (1.0 - (PLCTmin/QLCTmin)/(P5min/Q5min))/(5 - LCT) hspf2_array = [7.0, 9.25, 11.5, 13.75, 16.0] qm17full_array = [0.5, 0.54, 0.62, 0.78, 1.10] - cop47full_array = [[2.762, 2.696, 2.579, 2.467, 2.345], - [4.149, 3.941, 3.627, 3.305, 3.091], - [5.934, 5.490, 4.821, 4.167, 3.834], - [8.392, 7.463, 6.190, 5.054, 4.573], - [11.948, 10.060, 7.779, 5.967, 5.307]] + cop47full_array = [ + [2.762, 2.696, 2.579, 2.467, 2.345], + [4.149, 3.941, 3.627, 3.305, 3.091], + [5.934, 5.490, 4.821, 4.167, 3.834], + [8.392, 7.463, 6.190, 5.054, 4.573], + [11.948, 10.060, 7.779, 5.967, 5.307], + ] cop47full = interpolate_hspf2(hspf2, qm17full, hspf2_array, qm17full_array, cop47full_array) heat_capacity_ratios = [qr47min / qr47full, 1.0, 1.0 / qr47full] @@ -1072,9 +1061,15 @@ def interpolate_hspf2( copLCTmin = cop5min * (1.0 - eirmslopeLCTmin * (5.0 - lct_degF)) copLCTmax = cop5max * (1.0 - eirmslopeLCTmax * (5.0 - lct_degF)) if capacityLCTfull > 0: - copLCTfull = capacityLCTfull / interp2(capacity5full / cop5full, capacity5min / cop5min, capacity5max / cop5max, capacityLCTmin / copLCTmin, capacityLCTmax / copLCTmax) + copLCTfull = capacityLCTfull / interp2( + capacity5full / cop5full, + capacity5min / cop5min, + capacity5max / cop5max, + capacityLCTmin / copLCTmin, + capacityLCTmax / copLCTmax, + ) else: - copLCTfull = interp2(lct_degF, 5.0, 17.0, cop5min, cop17min) # Arbitrary + copLCTfull = interp2(lct_degF, 5.0, 17.0, cop5min, cop17min) # Arbitrary temp5 = round(convert(5.0, "degF", "degC"), 1) heating_performance[temp5] = {} @@ -1083,79 +1078,95 @@ def interpolate_hspf2( temp47 = utils_equipment.AIR_SOURCE_HEAT_RATED_ODB heating_performance[temp47] = {} if (capacityLCTmin is not None) or (capacityLCTfull is not None) or (capacityLCTmax is not None): - tempLCT = round(lct,1) + tempLCT = round(lct, 1) heating_performance[tempLCT] = {} if capacityLCTmin is not None: heating_performance[tempLCT][utils_equipment.HPXML_SPEED_DESCRIPTION_MINIMUM] = {} - heating_performance[tempLCT][utils_equipment.HPXML_SPEED_DESCRIPTION_MINIMUM]["capacity"] = round(capacityLCTmin, 2) + heating_performance[tempLCT][utils_equipment.HPXML_SPEED_DESCRIPTION_MINIMUM]["capacity"] = round( + capacityLCTmin, 2 + ) heating_performance[tempLCT][utils_equipment.HPXML_SPEED_DESCRIPTION_MINIMUM]["COP"] = round(copLCTmin, 2) if capacityLCTfull is not None: heating_performance[tempLCT][utils_equipment.HPXML_SPEED_DESCRIPTION_NOMINAL] = {} - heating_performance[tempLCT][utils_equipment.HPXML_SPEED_DESCRIPTION_NOMINAL]["capacity"] = round(capacityLCTfull, 2) + heating_performance[tempLCT][utils_equipment.HPXML_SPEED_DESCRIPTION_NOMINAL]["capacity"] = round( + capacityLCTfull, 2 + ) heating_performance[tempLCT][utils_equipment.HPXML_SPEED_DESCRIPTION_NOMINAL]["COP"] = round(copLCTfull, 2) if capacityLCTmax is not None: heating_performance[tempLCT][utils_equipment.HPXML_SPEED_DESCRIPTION_MAXIMUM] = {} - heating_performance[tempLCT][utils_equipment.HPXML_SPEED_DESCRIPTION_MAXIMUM]["capacity"] = round(capacityLCTmax, 2) + heating_performance[tempLCT][utils_equipment.HPXML_SPEED_DESCRIPTION_MAXIMUM]["capacity"] = round( + capacityLCTmax, 2 + ) heating_performance[tempLCT][utils_equipment.HPXML_SPEED_DESCRIPTION_MAXIMUM]["COP"] = round(copLCTmax, 2) if capacity5min is not None: heating_performance[temp5][utils_equipment.HPXML_SPEED_DESCRIPTION_MINIMUM] = {} - heating_performance[temp5][utils_equipment.HPXML_SPEED_DESCRIPTION_MINIMUM]["capacity"] = round(capacity5min, 2) + heating_performance[temp5][utils_equipment.HPXML_SPEED_DESCRIPTION_MINIMUM]["capacity"] = round( + capacity5min, 2 + ) heating_performance[temp5][utils_equipment.HPXML_SPEED_DESCRIPTION_MINIMUM]["COP"] = round(cop5min, 2) if capacity5full is not None: heating_performance[temp5][utils_equipment.HPXML_SPEED_DESCRIPTION_NOMINAL] = {} - heating_performance[temp5][utils_equipment.HPXML_SPEED_DESCRIPTION_NOMINAL]["capacity"] = round(capacity5full, 2) + heating_performance[temp5][utils_equipment.HPXML_SPEED_DESCRIPTION_NOMINAL]["capacity"] = round( + capacity5full, 2 + ) heating_performance[temp5][utils_equipment.HPXML_SPEED_DESCRIPTION_NOMINAL]["COP"] = round(cop5full, 2) if capacity5max is not None: heating_performance[temp5][utils_equipment.HPXML_SPEED_DESCRIPTION_MAXIMUM] = {} - heating_performance[temp5][utils_equipment.HPXML_SPEED_DESCRIPTION_MAXIMUM]["capacity"] = round(capacity5max, 2) + heating_performance[temp5][utils_equipment.HPXML_SPEED_DESCRIPTION_MAXIMUM]["capacity"] = round( + capacity5max, 2 + ) heating_performance[temp5][utils_equipment.HPXML_SPEED_DESCRIPTION_MAXIMUM]["COP"] = round(cop5max, 2) if capacity17min is not None: heating_performance[temp17][utils_equipment.HPXML_SPEED_DESCRIPTION_MINIMUM] = {} - heating_performance[temp17][utils_equipment.HPXML_SPEED_DESCRIPTION_MINIMUM]["capacity"] = round(capacity17min, 2) + heating_performance[temp17][utils_equipment.HPXML_SPEED_DESCRIPTION_MINIMUM]["capacity"] = round( + capacity17min, 2 + ) heating_performance[temp17][utils_equipment.HPXML_SPEED_DESCRIPTION_MINIMUM]["COP"] = round(cop17min, 2) if capacity17full is not None: heating_performance[temp17][utils_equipment.HPXML_SPEED_DESCRIPTION_NOMINAL] = {} - heating_performance[temp17][utils_equipment.HPXML_SPEED_DESCRIPTION_NOMINAL]["capacity"] = round(capacity17full, 2) + heating_performance[temp17][utils_equipment.HPXML_SPEED_DESCRIPTION_NOMINAL]["capacity"] = round( + capacity17full, 2 + ) heating_performance[temp17][utils_equipment.HPXML_SPEED_DESCRIPTION_NOMINAL]["COP"] = round(cop17full, 2) if capacity17max is not None: heating_performance[temp17][utils_equipment.HPXML_SPEED_DESCRIPTION_MAXIMUM] = {} - heating_performance[temp17][utils_equipment.HPXML_SPEED_DESCRIPTION_MAXIMUM]["capacity"] = round(capacity17max, 2) + heating_performance[temp17][utils_equipment.HPXML_SPEED_DESCRIPTION_MAXIMUM]["capacity"] = round( + capacity17max, 2 + ) heating_performance[temp17][utils_equipment.HPXML_SPEED_DESCRIPTION_MAXIMUM]["COP"] = round(cop17max, 2) if capacity47min is not None: heating_performance[temp47][utils_equipment.HPXML_SPEED_DESCRIPTION_MINIMUM] = {} - heating_performance[temp47][utils_equipment.HPXML_SPEED_DESCRIPTION_MINIMUM]["capacity"] = round(capacity47min, 2) + heating_performance[temp47][utils_equipment.HPXML_SPEED_DESCRIPTION_MINIMUM]["capacity"] = round( + capacity47min, 2 + ) heating_performance[temp47][utils_equipment.HPXML_SPEED_DESCRIPTION_MINIMUM]["COP"] = round(cop47min, 2) if capacity47full is not None: heating_performance[temp47][utils_equipment.HPXML_SPEED_DESCRIPTION_NOMINAL] = {} - heating_performance[temp47][utils_equipment.HPXML_SPEED_DESCRIPTION_NOMINAL]["capacity"] = round(capacity47full, 2) + heating_performance[temp47][utils_equipment.HPXML_SPEED_DESCRIPTION_NOMINAL]["capacity"] = round( + capacity47full, 2 + ) heating_performance[temp47][utils_equipment.HPXML_SPEED_DESCRIPTION_NOMINAL]["COP"] = round(cop47full, 2) if capacity47max is not None: heating_performance[temp47][utils_equipment.HPXML_SPEED_DESCRIPTION_MAXIMUM] = {} - heating_performance[temp47][utils_equipment.HPXML_SPEED_DESCRIPTION_MAXIMUM]["capacity"] = round(capacity47max, 2) + heating_performance[temp47][utils_equipment.HPXML_SPEED_DESCRIPTION_MAXIMUM]["capacity"] = round( + capacity47max, 2 + ) heating_performance[temp47][utils_equipment.HPXML_SPEED_DESCRIPTION_MAXIMUM]["COP"] = round(cop47max, 2) return heating_performance - def set_default_cooling_detailed_performance( - number_of_speeds, seer2, eer2, c_d, capacity - ): + def set_default_cooling_detailed_performance(number_of_speeds, seer2, eer2, c_d, capacity): # Calculates COP82min from SEER2 using bi-linear interpolation per RESNET MINERS Addendum 82 - def interpolate_seer2( - seer2, eer2, seer2_array, seer2_eer2_ratio_array, cop82min_array - ): + def interpolate_seer2(seer2, eer2, seer2_array, seer2_eer2_ratio_array, cop82min_array): seer2_eer2_ratio = seer2 / eer2 x1, x2 = find_array_neighbor_values(sorted(seer2_array), seer2) - y1, y2 = find_array_neighbor_values( - sorted(seer2_eer2_ratio_array), seer2_eer2_ratio - ) + y1, y2 = find_array_neighbor_values(sorted(seer2_eer2_ratio_array), seer2_eer2_ratio) x_indexes = [seer2_array.index(x) for x in [x1, x2]] y_indexes = [seer2_eer2_ratio_array.index(x) for x in [y1, y2]] fx1y1 = cop82min_array[x_indexes[0]][y_indexes[0]] fx1y2 = cop82min_array[x_indexes[0]][y_indexes[1]] fx2y1 = cop82min_array[x_indexes[1]][y_indexes[0]] fx2y2 = cop82min_array[x_indexes[1]][y_indexes[1]] - return interp4( - seer2, seer2_eer2_ratio, x1, x2, y1, y2, fx1y1, fx1y2, fx2y1, fx2y2 - ) + return interp4(seer2, seer2_eer2_ratio, x1, x2, y1, y2, fx1y1, fx1y2, fx2y1, fx2y2) cooling_performance = {} # Datapoints to be defaulted @@ -1200,9 +1211,7 @@ def interpolate_seer2( cop82full = cop95full * eirm95full # 82F min speed capacity82min = capacity82full * cool_capacity_ratios[0] - cop82min = interpolate_seer2( - seer2, eer2, seer2_array, seer2_eer2_ratio_array, cop82min_array - ) + cop82min = interpolate_seer2(seer2, eer2, seer2_array, seer2_eer2_ratio_array, cop82min_array) # 95F min speed capacity95min = capacity95full * cool_capacity_ratios[0] cop95min = cop82min / eirm95full @@ -1221,17 +1230,13 @@ def interpolate_seer2( [7.061, 10.289, 23.262, 31.842, 37.513], [10.058, 14.053, 30.962, 42.388, 49.863], ] - cop82min = interpolate_seer2( - seer2, eer2, seer2_array, seer2_eer2_ratio_array, cop82min_array - ) + cop82min = interpolate_seer2(seer2, eer2, seer2_array, seer2_eer2_ratio_array, cop82min_array) cop95max = cop95full * eirr95full cop95min = cop82min / eirm95min cop82max = cop95max * eirm95max cop82full = interp2(cop95full, cop95min, cop95max, cop82min, cop82max) - qr95min = ( - 1.0 / qr95full * (0.029 + 0.369 * cop82max / cop82min) - ) # Q95min/Q95max + qr95min = 1.0 / qr95full * (0.029 + 0.369 * cop82max / cop82min) # Q95min/Q95max cool_capacity_ratios = [qr95min, 1.0, 1.0 / qr95full] capacity95max = capacity95full * cool_capacity_ratios[-1] @@ -1244,27 +1249,39 @@ def interpolate_seer2( cooling_performance[temp95] = {} if capacity82min is not None: cooling_performance[temp82][utils_equipment.HPXML_SPEED_DESCRIPTION_MINIMUM] = {} - cooling_performance[temp82][utils_equipment.HPXML_SPEED_DESCRIPTION_MINIMUM]["capacity"] = round(capacity82min, 2) + cooling_performance[temp82][utils_equipment.HPXML_SPEED_DESCRIPTION_MINIMUM]["capacity"] = round( + capacity82min, 2 + ) cooling_performance[temp82][utils_equipment.HPXML_SPEED_DESCRIPTION_MINIMUM]["COP"] = round(cop82min, 2) if capacity82full is not None: cooling_performance[temp82][utils_equipment.HPXML_SPEED_DESCRIPTION_NOMINAL] = {} - cooling_performance[temp82][utils_equipment.HPXML_SPEED_DESCRIPTION_NOMINAL]["capacity"] = round(capacity82full, 2) + cooling_performance[temp82][utils_equipment.HPXML_SPEED_DESCRIPTION_NOMINAL]["capacity"] = round( + capacity82full, 2 + ) cooling_performance[temp82][utils_equipment.HPXML_SPEED_DESCRIPTION_NOMINAL]["COP"] = round(cop82full, 2) if capacity82max is not None: cooling_performance[temp82][utils_equipment.HPXML_SPEED_DESCRIPTION_MAXIMUM] = {} - cooling_performance[temp82][utils_equipment.HPXML_SPEED_DESCRIPTION_MAXIMUM]["capacity"] = round(capacity82max, 2) + cooling_performance[temp82][utils_equipment.HPXML_SPEED_DESCRIPTION_MAXIMUM]["capacity"] = round( + capacity82max, 2 + ) cooling_performance[temp82][utils_equipment.HPXML_SPEED_DESCRIPTION_MAXIMUM]["COP"] = round(cop82max, 2) if capacity95min is not None: cooling_performance[temp95][utils_equipment.HPXML_SPEED_DESCRIPTION_MINIMUM] = {} - cooling_performance[temp95][utils_equipment.HPXML_SPEED_DESCRIPTION_MINIMUM]["capacity"] = round(capacity95min, 2) + cooling_performance[temp95][utils_equipment.HPXML_SPEED_DESCRIPTION_MINIMUM]["capacity"] = round( + capacity95min, 2 + ) cooling_performance[temp95][utils_equipment.HPXML_SPEED_DESCRIPTION_MINIMUM]["COP"] = round(cop95min, 2) if capacity95full is not None: cooling_performance[temp95][utils_equipment.HPXML_SPEED_DESCRIPTION_NOMINAL] = {} - cooling_performance[temp95][utils_equipment.HPXML_SPEED_DESCRIPTION_NOMINAL]["capacity"] = round(capacity95full, 2) + cooling_performance[temp95][utils_equipment.HPXML_SPEED_DESCRIPTION_NOMINAL]["capacity"] = round( + capacity95full, 2 + ) cooling_performance[temp95][utils_equipment.HPXML_SPEED_DESCRIPTION_NOMINAL]["COP"] = round(cop95full, 2) if capacity95max is not None: cooling_performance[temp95][utils_equipment.HPXML_SPEED_DESCRIPTION_MAXIMUM] = {} - cooling_performance[temp95][utils_equipment.HPXML_SPEED_DESCRIPTION_MAXIMUM]["capacity"] = round(capacity95max, 2) + cooling_performance[temp95][utils_equipment.HPXML_SPEED_DESCRIPTION_MAXIMUM]["capacity"] = round( + capacity95max, 2 + ) cooling_performance[temp95][utils_equipment.HPXML_SPEED_DESCRIPTION_MAXIMUM]["COP"] = round(cop95max, 2) return cooling_performance @@ -1317,11 +1334,7 @@ def sort_detailed_performance(performance): } if has_heat_pump or hvac_type == "Cooling": # TODO: ERROR checking for unsupported system types. - if name in [ - "mini-split", - "air-to-air", - "central air conditioner" - ]: + if name in ["mini-split", "air-to-air", "central air conditioner"]: if hvac.get("CompressorType") in speed_options: number_of_speeds = speed_options[hvac.get("CompressorType")] else: # CompressorType is now a required input in OS-HPXML for specific system types @@ -1332,8 +1345,10 @@ def sort_detailed_performance(performance): # Efficiency input and conversion efficiency_map = {} efficiencies = hvac.get(f"Annual{hvac_type}Efficiency", {}) - valid_efficiency_units = {"Cooling": ["SEER", "SEER2", "EER", "EER2"], - "Heating": ["Percent", "AFUE", "HSPF", "HSPF2"]} + valid_efficiency_units = { + "Cooling": ["SEER", "SEER2", "EER", "EER2"], + "Heating": ["Percent", "AFUE", "HSPF", "HSPF2"], + } # handle one or more efficiency elements efficiencies = efficiencies if isinstance(efficiencies, list) else [efficiencies] for efficiency in efficiencies: @@ -1342,10 +1357,8 @@ def sort_detailed_performance(performance): if efficiency["Units"] in valid_efficiency_units[hvac_type]: efficiency_map[efficiency["Units"]] = efficiency["Value"] else: - raise OCHREException( - f"Unknown inputs for HVAC {hvac_type} efficiency: {efficiency}" - ) - + raise OCHREException(f"Unknown inputs for HVAC {hvac_type} efficiency: {efficiency}") + is_ducted = bool(hvac_all.get("HVACDistribution", {}).get("DistributionSystemType", {}).get("AirDistribution")) hvac_ext = hvac.get("extension", {}) detailed_performance_sys_types = ["air-to-air", "mini-split", "central air conditioner"] @@ -1359,7 +1372,9 @@ def sort_detailed_performance(performance): efficiency_map["EER2"] = utils_equipment.calc_eer2_from_eer(efficiency_map["EER"], is_ducted) elif "SEER2" in efficiency_map.keys(): # Default EER2 based on SEER2 - efficiency_map["EER2"] = utils_equipment.calc_eer2_from_seer2(efficiency_map["SEER2"], number_of_speeds) + efficiency_map["EER2"] = utils_equipment.calc_eer2_from_seer2( + efficiency_map["SEER2"], number_of_speeds + ) else: raise OCHREException( f"Missing inputs for HVAC {hvac_type} efficiency, need SEER/SEER2, or EER2/EER2 to be provided." @@ -1369,9 +1384,7 @@ def sort_detailed_performance(performance): if "HSPF" in efficiency_map.keys(): efficiency_map["HSPF2"] = utils_equipment.calc_hspf2_from_hspf(efficiency_map["HSPF"], is_ducted) else: - raise OCHREException( - f"Missing inputs for HVAC {hvac_type} efficiency, need HSPF to be provided." - ) + raise OCHREException(f"Missing inputs for HVAC {hvac_type} efficiency, need HSPF to be provided.") # Get SHR is_heater = hvac_type == "Heating" @@ -1407,7 +1420,9 @@ def sort_detailed_performance(performance): # Note: air flow rate is only used for non-dymanic HVAC models with fans, e.g., furnaces # airflow_cfm = hvac_ext.get(f'{hvac_type}AirflowCFM', 0) if design_airflow_cfm is None: - design_airflow_cfm = utils_equipment.get_design_cfm_per_ton(name) * convert(capacity, "W", "refrigeration_ton") + design_airflow_cfm = utils_equipment.get_design_cfm_per_ton(name) * convert( + capacity, "W", "refrigeration_ton" + ) power_per_cfm = hvac_ext.get("FanPowerWattsPerCFM", 0) aux_power = power_per_cfm * design_airflow_cfm else: @@ -1416,9 +1431,7 @@ def sort_detailed_performance(performance): if "FanMotorType" in hvac_ext: fan_motor_type = hvac_ext.get("FanMotorType") elif name in detailed_performance_sys_types: - raise OCHREException( - f"Missing inputs for Fan Motor Type for system: {name}." - ) + raise OCHREException(f"Missing inputs for Fan Motor Type for system: {name}.") else: fan_motor_type = None @@ -1463,7 +1476,7 @@ def sort_detailed_performance(performance): } ) - lct = None # Heat pump min temperature used for detailed performance data + lct = None # Heat pump min temperature used for detailed performance data if has_heat_pump and hvac_type == "Heating": backup_fuel = heat_pump.get("BackupSystemFuel") backup_capacity = heat_pump.get("BackupHeatingCapacity", 0) @@ -1498,17 +1511,13 @@ def sort_detailed_performance(performance): if hvac_type == "Cooling": rated_temp = utils_equipment.AIR_SOURCE_COOL_RATED_ODB if heat_pump.get("CoolingDetailedPerformanceData") is not None: - cooling_detailed_performance_data = heat_pump.get( - "CoolingDetailedPerformanceData" - ) + cooling_detailed_performance_data = heat_pump.get("CoolingDetailedPerformanceData") cooling_detailed_performance_data_dict = get_detailed_performance_data( cooling_detailed_performance_data ) else: - cooling_detailed_performance_data_dict = ( - set_default_cooling_detailed_performance( - number_of_speeds, efficiency_map["SEER2"], efficiency_map["EER2"], c_d, capacity - ) + cooling_detailed_performance_data_dict = set_default_cooling_detailed_performance( + number_of_speeds, efficiency_map["SEER2"], efficiency_map["EER2"], c_d, capacity ) out["CoolingDetailedPerformance"] = sort_detailed_performance(cooling_detailed_performance_data_dict) if "nominal" in out["CoolingDetailedPerformance"][rated_temp].keys(): @@ -1522,17 +1531,13 @@ def sort_detailed_performance(performance): ) elif hvac_type == "Heating": if heat_pump.get("HeatingDetailedPerformanceData") is not None: - heating_detailed_performance_data = heat_pump.get( - "HeatingDetailedPerformanceData" - ) + heating_detailed_performance_data = heat_pump.get("HeatingDetailedPerformanceData") heating_detailed_performance_data_dict = get_detailed_performance_data( heating_detailed_performance_data ) else: - heating_detailed_performance_data_dict = ( - set_default_heating_detailed_performance( - number_of_speeds, efficiency_map["HSPF2"], capacity17f / capacity, capacity, lct - ) + heating_detailed_performance_data_dict = set_default_heating_detailed_performance( + number_of_speeds, efficiency_map["HSPF2"], capacity17f / capacity, capacity, lct ) out["HeatingDetailedPerformance"] = sort_detailed_performance(heating_detailed_performance_data_dict) rated_temp = utils_equipment.AIR_SOURCE_HEAT_RATED_ODB @@ -1555,9 +1560,7 @@ def sort_detailed_performance(performance): elif "AFUE" in efficiency_map.keys(): out.update({"EIR (-)": 1 / efficiency_map["AFUE"]}) else: - raise OCHREException( - "Efficiency COP not provided or calculated." - ) + raise OCHREException("Efficiency COP not provided or calculated.") # Get duct info for calculating DSE distribution = hvac_all.get("HVACDistribution", {}) distribution_type = distribution.get("DistributionSystemType", {}) @@ -1665,26 +1668,19 @@ def interpolate_to_odb_table_point( right_odb = max(user_odbs) left_odb = min(user_odbs) - slope = ( - detailed_performance_data[right_odb][property] - - detailed_performance_data[left_odb][property] - ) / (right_odb - left_odb) - val = (target_odb - left_odb) * slope + detailed_performance_data[left_odb][ - property - ] + slope = (detailed_performance_data[right_odb][property] - detailed_performance_data[left_odb][property]) / ( + right_odb - left_odb + ) + val = (target_odb - left_odb) * slope + detailed_performance_data[left_odb][property] return val -def interpolate_to_odb_table_points( - detailed_performance_data, mode, compressor_lockout_temp, weather_temp -): +def interpolate_to_odb_table_points(detailed_performance_data, mode, compressor_lockout_temp, weather_temp): user_odbs = list(detailed_performance_data.keys()) # can do this or just code the variable names into the high/low odb at zero cop/capacity variables , TODO: update with gross cop/capacity - properties = list( - detailed_performance_data[list(detailed_performance_data.keys())[0]].keys() - ) + properties = list(detailed_performance_data[list(detailed_performance_data.keys())[0]].keys()) for property in properties: if "min" in properties[property] and "cop" in properties[property]: low_cop = property @@ -1712,12 +1708,8 @@ def interpolate_to_odb_table_points( detailed_performance_data, user_odbs, low_capacity, False ) - outdoor_dry_bulbs += [ - max(low_odb_at_zero_cop, low_odb_at_zero_capacity, 55) - ] # min cooling odb - outdoor_dry_bulbs += [ - min(high_odb_at_zero_cop, high_odb_at_zero_capacity, weather_temp) - ] # max cooling odb + outdoor_dry_bulbs += [max(low_odb_at_zero_cop, low_odb_at_zero_capacity, 55)] # min cooling odb + outdoor_dry_bulbs += [min(high_odb_at_zero_cop, high_odb_at_zero_capacity, weather_temp)] # max cooling odb else: outdoor_dry_bulbs = [] # TODO: update with gross cop/capacity @@ -1727,9 +1719,7 @@ def interpolate_to_odb_table_points( high_odb_at_zero_capacity = calculate_odb_at_zero_cop_or_capacity( detailed_performance_data, user_odbs, high_capacity, False ) - low_odb_at_zero_cop = calculate_odb_at_zero_cop_or_capacity( - detailed_performance_data, user_odbs, low_cop, True - ) + low_odb_at_zero_cop = calculate_odb_at_zero_cop_or_capacity(detailed_performance_data, user_odbs, low_cop, True) low_odb_at_zero_capacity = calculate_odb_at_zero_cop_or_capacity( detailed_performance_data, user_odbs, low_capacity, True ) @@ -1742,19 +1732,15 @@ def interpolate_to_odb_table_points( weather_temp, ) ] # min heating odb - outdoor_dry_bulbs += [ - min(high_odb_at_zero_cop, high_odb_at_zero_capacity, 60) - ] # max heating odb + outdoor_dry_bulbs += [min(high_odb_at_zero_cop, high_odb_at_zero_capacity, 60)] # max heating odb for target_odb in outdoor_dry_bulbs: if target_odb not in user_odbs: # make new sub dictionary for this in the detailed performance data dictionary detailed_performance_data[target_odb] = {} for property in properties: # TODO: update with gross cop/capacity - detailed_performance_data[target_odb][property] = ( - interpolate_to_odb_table_point( - detailed_performance_data, property, target_odb - ) + detailed_performance_data[target_odb][property] = interpolate_to_odb_table_point( + detailed_performance_data, property, target_odb ) @@ -1835,9 +1821,7 @@ def parse_water_heater(water_heater, water, construction, solar_fraction=0): t = 125.0 # F if first_hour_rating < 18.0: volume_drawn = 10.0 # gal - elif ( - first_hour_rating < 51.0 - ): # Includes 18 gal up to (but not including) 51 + elif first_hour_rating < 51.0: # Includes 18 gal up to (but not including) 51 volume_drawn = 38.0 # gal elif first_hour_rating < 75.0: volume_drawn = 55.0 # gal @@ -2070,9 +2054,7 @@ def parse_clothes_dryer(clothes_dryer, clothes_washer, n_bedrooms): else: elec_btu = convert(annual_kwh, "kWh", "Btu") gas_btu = annual_therm * 1e5 - frac_sens = (1.0 - frac_lost) * ( - (0.90 * elec_btu + 0.8894 * gas_btu) / (elec_btu + gas_btu) - ) + frac_sens = (1.0 - frac_lost) * ((0.90 * elec_btu + 0.8894 * gas_btu) / (elec_btu + gas_btu)) frac_lat = 1.0 - frac_sens - frac_lost return { @@ -2105,9 +2087,7 @@ def parse_dishwasher(dishwasher, n_bedrooms): annual_kwh = kwh_per_cyc * dwcpy annual_kwh *= multiplier - water_draw = ( - (rated_annual_kwh - kwh_per_cyc * usage) * 0.02504 * dwcpy / 365.0 - ) # in gal/day + water_draw = (rated_annual_kwh - kwh_per_cyc * usage) * 0.02504 * dwcpy / 365.0 # in gal/day water_draw *= multiplier frac_lost = 0.40 @@ -2131,9 +2111,7 @@ def parse_refrigerator(refrigerators, n_bedrooms): extension_1 = refrigerators[0].get("extension", {}) if len(refrigerators) >= 2: - print( - f"Note: Combining {len(refrigerators)} refrigerators into 1 piece of equipment." - ) + print(f"Note: Combining {len(refrigerators)} refrigerators into 1 piece of equipment.") assert all([r.get("extension", {}) == extension_1 for r in refrigerators]) annual_kwh = 0 @@ -2224,11 +2202,7 @@ def parse_cooking_range(range_dict, oven_dict, n_bedrooms): annual_gas_kwh = convert(annual_therm, "therm", "kWh") annual_total_kwh = annual_kwh + annual_gas_kwh if annual_total_kwh != 0: - frac_sens = ( - (1.0 - frac_lost) - * (0.90 * annual_kwh + 0.7942 * annual_gas_kwh) - / annual_total_kwh - ) + frac_sens = (1.0 - frac_lost) * (0.90 * annual_kwh + 0.7942 * annual_gas_kwh) / annual_total_kwh else: frac_sens = 0 frac_lat = 1.0 - frac_sens - frac_lost