diff --git a/ochre/Equipment/HVAC.py b/ochre/Equipment/HVAC.py index 5a238d6c..cb17a75b 100644 --- a/ochre/Equipment/HVAC.py +++ b/ochre/Equipment/HVAC.py @@ -2,7 +2,7 @@ import numpy as np import psychrolib -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 @@ -10,7 +10,7 @@ SPEED_TYPES = { 1: "Single", 2: "Double", - 4: "Variable", + 3: "Variable", # 10: 'Mini-split Variable', # Note: MSHP model uses 4 speeds, not 10 } @@ -54,10 +54,17 @@ 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 + 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 @@ -82,32 +89,43 @@ def __init__(self, envelope_model=None, use_ideal_capacity=None, **kwargs): self.shr = shr_list[self.speed_idx] # Air flow parameters - 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 + 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" + ) else: - # Use nominal flow rates, values taken from ResStock (see hvac.rb line 2623) - cfm_per_ton = 350 if self.is_heater else 312 - 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) + 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 - rated_fan_power = kwargs["Rated Auxiliary Power (W)"] - self.fan_power_per_flow_rate = rated_fan_power / 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_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( + 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 @@ -158,8 +176,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:]) @@ -706,6 +724,12 @@ class DynamicHVAC(HVAC): 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 self.n_speeds = kwargs.get("Number of Speeds (-)", 1) @@ -716,91 +740,161 @@ 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) # 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 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: - 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) - speed_params = df_speed.loc[ - (df_speed["HVAC Name"] == self.name) - & (df_speed["HVAC Efficiency"] == rated_efficiency) - & (df_speed["Number of Speeds"] == self.n_speeds) - ] - if not len(speed_params): + # 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] + 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( - f"Cannot find multispeed parameters for {self.n_speeds}-speed {rated_efficiency} {self.name}" + "Detailed performance data is required for dynamic HVAC equipment other than Room AC." ) - 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 (-)"]] + 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_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) + 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): + 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", + ) + 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]], + 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_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", + ) + 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]], + 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: 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: @@ -890,36 +984,36 @@ 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: + if speed_idx == 0: 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"]) - # 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 = curve_t.evaluate(t_in, t_ext_db) + ff_ratio = curve_ff.evaluate(flow_fraction) + plf_ratio = 1.0 - 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"]) + if param == "eir": + plf_ratio = curve_plr.evaluate(part_load_ratio) return rated * t_ratio * ff_ratio / plf_ratio @@ -952,7 +1046,7 @@ def update_capacity(self): 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() @@ -1019,8 +1113,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() @@ -1077,8 +1171,12 @@ 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 @@ -1228,7 +1326,12 @@ def parse_duty_cycles(self, control_signal): 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 @@ -1355,7 +1458,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 @@ -1450,3 +1556,274 @@ 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 + 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 + self.min_x2 = -100.0 + self.max_x2 = 100.0 + 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): + 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") + 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, 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 + 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 + 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") + 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)) + + 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 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 8ea8063d..d1e426b3 100644 --- a/ochre/utils/equipment.py +++ b/ochre/utils/equipment.py @@ -42,6 +42,30 @@ }, } +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 @@ -465,39 +489,50 @@ def calculate_duct_dse( 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 == 3: + 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 == 3: + 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 @@ -678,6 +713,266 @@ def calculate_mass_flow_rate(DBin, Win, P, flow): 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, +): + 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) + 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") + + 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, + ] + # 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 + # 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: + 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 + 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"], + ) + # corrected capacity hash, with two temperature independent variables + 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, +): + 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." + ) + + # 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 + 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 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 f002258e..b792c8f7 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 @@ -823,9 +824,483 @@ def parse_hpxml_occupancy(hpxml): def parse_hvac(hvac_type, hvac_all): + 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 get_detailed_performance_data(detailed_performance_data): + performance = {} + 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 + 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][speed]["capacity"] = round(capacity_w, 2) + + performance[out_temp][speed]["COP"] = round(cop, 2) + + return performance + + 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): + 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 + + 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 == 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) + + 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][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][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][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][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][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][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][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][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][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][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][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][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(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): + 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 = convert(eer2, "Btu/hour", "W") + + 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 == 3: + 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 + 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[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][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][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][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): + 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") + # 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.") elif not system and not heat_pump: @@ -842,36 +1317,74 @@ def parse_hvac(hvac_type, hvac_all): # 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) - 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']}" - # Get number of speeds speed_options = { "single stage": 1, "two stage": 2, - "variable speed": 4, + "variable speed": 3, } - 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": + # TODO: ERROR checking for unsupported system types. + 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 + raise OCHREException("HVAC missing CompressorType input.") + else: + number_of_speeds = 1 + + # 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"], + } + # 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]: + efficiency_map[efficiency["Units"]] = efficiency["Value"] + else: + 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"] + 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" @@ -882,8 +1395,23 @@ def parse_hvac(hvac_type, hvac_all): 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_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 + elif number_of_speeds == 3: + 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 - 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 @@ -891,27 +1419,39 @@ def parse_hvac(hvac_type, hvac_all): 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 + 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") + aux_power = 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: + raise OCHREException(f"Missing inputs for Fan Motor Type for system: {name}.") + else: + fan_motor_type = None + out = { "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, "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 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 @@ -936,6 +1476,7 @@ def parse_hvac(hvac_type, hvac_all): } ) + 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) @@ -952,6 +1493,7 @@ def parse_hvac(hvac_type, hvac_all): 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(f"WARNING: Using electric resistance backup for ASHP instead of {backup_fuel} backup") @@ -965,6 +1507,60 @@ def parse_hvac(hvac_type, hvac_all): } ) + 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") + 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 + ) + 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"]) + > 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") + 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 + ) + out["HeatingDetailedPerformance"] = sort_detailed_performance(heating_detailed_performance_data_dict) + rated_temp = utils_equipment.AIR_SOURCE_HEAT_RATED_ODB + 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"]) + > capacity * 0.01 + ): + raise OCHREException( + "Heating nominal capacity inputs not consistent. Check HeatingCapacity and HeatingDetailedPerformanceData." + ) + + 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", {}) @@ -1008,6 +1604,146 @@ 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 # If using EF: @@ -1757,6 +2493,171 @@ def parse_ev_from_vehicle(vehicle, charger_dict): return result +def parse_pv(pv_system, inverter_dict): + """ + Parse PV system from HPXML Photovoltaics section. + + Args: + pv_system: Dict containing PVSystem data from HPXML + inverter_dict: Dict of inverters keyed by SystemIdentifier id + + Returns: + Dict with PV parameters for OCHRE (capacity, tilt, azimuth, inverter_efficiency, inverter_capacity) + or None if required data is missing + """ + print("Creating PV equipment from Photovoltaics section in HPXML") + + # Extract required parameters + max_power_output = pv_system.get("MaxPowerOutput") # in W + if not max_power_output: + print("WARNING: PV system missing MaxPowerOutput, skipping PV parsing") + return None + + capacity = max_power_output / 1000 # convert W to kW + + # Extract tilt and azimuth (optional - OCHRE can calculate from roof if missing) + tilt = pv_system.get("ArrayTilt") + azimuth_hpxml = pv_system.get("ArrayAzimuth") + + # Convert azimuth from HPXML convention to OCHRE convention + # HPXML: 0=north, 90=east, 180=south, 270=west + # OCHRE: 0=south, 90=west, 180=north, 270=east (west-of-south is positive) + # Conversion: ochre_azimuth = (hpxml_azimuth - 180) % 360 + azimuth = None + if azimuth_hpxml is not None: + azimuth = (azimuth_hpxml - 180) % 360 + + # Get inverter information + inverter_efficiency = None + inverter_capacity = None + + inverter_ref = pv_system.get("AttachedToInverter", {}).get("idref") + if inverter_ref and inverter_ref in inverter_dict: + inverter = inverter_dict[inverter_ref] + inv_eff = inverter.get("InverterEfficiency") + if inv_eff: + # Convert from fraction to percentage (0.96 -> 96) + inverter_efficiency = inv_eff * 100 + else: + if inverter_ref: + print(f'WARNING: Could not find inverter with id "{inverter_ref}", using default efficiency (96%)') + + result = { + "capacity": capacity, # kW (DC) + } + + # Add optional parameters if available + if tilt is not None: + result["tilt"] = tilt + if azimuth is not None: + result["azimuth"] = azimuth + if inverter_efficiency is not None: + result["inverter_efficiency"] = inverter_efficiency + if inverter_capacity is not None: + result["inverter_capacity"] = inverter_capacity + + return result + + +def parse_ev_from_vehicle(vehicle, charger_dict): + """ + Parse EV equipment from HPXML Vehicle and ElectricVehicleCharger sections. + + Args: + vehicle: Dict containing Vehicle data from HPXML + charger_dict: Dict of chargers keyed by SystemIdentifier id + + Returns: + Dict with EV parameters for OCHRE (vehicle_type, charging_level, range, capacity, max_power) + or None if required data is missing + """ + print("Creating EV equipment from Vehicle section in HPXML") + + # Determine vehicle type (BEV vs PHEV) + vehicle_type_data = vehicle.get("VehicleType", {}) + if "BatteryElectricVehicle" in vehicle_type_data: + vehicle_type = "BEV" + vehicle_data = vehicle_type_data["BatteryElectricVehicle"] + elif "PlugInHybridElectricVehicle" in vehicle_type_data: + vehicle_type = "PHEV" + vehicle_data = vehicle_type_data["PlugInHybridElectricVehicle"] + else: + print("WARNING: Unknown vehicle type, defaulting to BEV") + vehicle_type = "BEV" + vehicle_data = {} + + # Extract battery capacity (kWh) + battery = vehicle_data.get("Battery", {}) + capacity = None + if "UsableCapacity" in battery: + capacity = battery["UsableCapacity"].get("Value") + elif "NominalCapacity" in battery: + capacity = battery["NominalCapacity"].get("Value") + if capacity: + print("WARNING: Using NominalCapacity instead of UsableCapacity for EV battery") + + # Calculate or extract vehicle range + range_miles = None + fuel_economy = vehicle.get("FuelEconomyCombined", {}).get("Value") # kWh/mile + + if capacity is not None: + # Use capacity to calculate range (default: 325 Wh/mile efficiency = 1/325 miles/Wh) + range_miles = capacity * 1000 / 325 # convert kWh to Wh, then to miles + elif fuel_economy and "MilesDrivenPerYear" in vehicle: + # Alternative: use fuel economy and annual miles + annual_miles = vehicle["MilesDrivenPerYear"] + annual_kwh = annual_miles * fuel_economy + # Estimate capacity assuming 250 charges per year + capacity = annual_kwh / 250 + range_miles = capacity * 1000 / 325 + + # Classify vehicle size for OCHRE + if range_miles: + if vehicle_type == "PHEV": + range_ochre = 20 if range_miles < 35 else 50 + else: # BEV + range_ochre = 100 if range_miles < 175 else 250 + else: + print("WARNING: Could not determine EV range, defaulting to 250 miles for BEV") + range_ochre = 250 + range_miles = 250 + + # Get charging information + charging_level = "Level 2" # default + max_power = None + + # Find connected charger + charger_ref = vehicle_data.get("ConnectedCharger", {}).get("idref") + if charger_ref and charger_ref in charger_dict: + charger = charger_dict[charger_ref] + charging_level_num = charger.get("ChargingLevel", 2) + charging_level = f"Level {charging_level_num}" + + # Get charging power (convert W to kW) + charging_power_w = charger.get("ChargingPower") + if charging_power_w: + max_power = charging_power_w / 1000 # convert W to kW + else: + if charger_ref: + print(f'WARNING: Could not find charger with id "{charger_ref}", using Level 2 defaults') + else: + print("WARNING: No charger connected to vehicle, using Level 2 defaults") + + result = { + "vehicle_type": vehicle_type, + "charging_level": charging_level, + "range": range_ochre, + } + + # Add optional parameters if available + if capacity: + result["capacity"] = capacity + if max_power: + result["max_power"] = max_power + + return result + + def parse_pool_equipment(hpxml): # Get pool and spa equipment pool_equipment = {}