# BSD 3-Clause License # # Copyright (c) 2022, Denis Bourgeois # All rights reserved. # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, this # list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # 3. Neither the name of the copyright holder nor the names of its # contributors may be used to endorse or promote products derived from # this software without specific prior written permission. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE # DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE # FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL # DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR # SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, # OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. require "openstudio" module OSut extend OSlg # DEBUG for devs; WARN/ERROR for users (bad OS input) TOL = 0.01 TOL2 = TOL * TOL NS = "nameString" # OpenStudio IdfObject nameString method DBG = OSut::DEBUG # mainly to flag invalid arguments to devs (buggy code) INF = OSut::INFO # not currently used in OSut WRN = OSut::WARN # WARN users of 'iffy' .osm inputs (yet not critical) ERR = OSut::ERROR # flag invalid .osm inputs (then exit via 'return') FTL = OSut::FATAL # not currently used in OSut # This first set of utilities (~750 lines) help distinguishing spaces that # are directly vs indirectly CONDITIONED, vs SEMI-HEATED. The solution here # relies as much as possible on space conditioning categories found in # standards like ASHRAE 90.1 and energy codes like the Canadian NECB editions. # Both documents share many similarities, regardless of nomenclature. There # are however noticeable differences between approaches on how a space is # tagged as falling into one of the aforementioned categories. First, an # overview of 90.1 requirements, with some minor edits for brevity/emphasis: # # www.pnnl.gov/main/publications/external/technical_reports/PNNL-26917.pdf # # 3.2.1. General Information - SPACE CONDITIONING CATEGORY # # - CONDITIONED space: an ENCLOSED space that has a heating and/or # cooling system of sufficient size to maintain temperatures suitable # for HUMAN COMFORT: # - COOLED: cooled by a system >= 10 W/m2 # - HEATED: heated by a system e.g., >= 50 W/m2 in Climate Zone CZ-7 # - INDIRECTLY: heated or cooled via adjacent space(s) provided: # - UA of adjacent surfaces > UA of other surfaces # or # - intentional air transfer from HEATED/COOLED space > 3 ACH # # ... includes plenums, atria, etc. # # - SEMI-HEATED space: an ENCLOSED space that has a heating system # >= 10 W/m2, yet NOT a CONDITIONED space (see above). # # - UNCONDITIONED space: an ENCLOSED space that is NOT a conditioned # space or a SEMI-HEATED space (see above). # # NOTE: Crawlspaces, attics, and parking garages with natural or # mechanical ventilation are considered UNENCLOSED spaces. # # 2.3.3 Modeling Requirements: surfaces adjacent to UNENCLOSED spaces # shall be treated as exterior surfaces. All other UNENCLOSED surfaces # are to be modeled as is in both proposed and baseline models. For # instance, modeled fenestration in UNENCLOSED spaces would not be # factored in WWR calculations. # # # Related NECB definitions and concepts, starting with CONDITIONED space: # # "[...] the temperature of which is controlled to limit variation in # response to the exterior ambient temperature by the provision, either # DIRECTLY or INDIRECTLY, of heating or cooling [...]". Although criteria # differ (e.g., not sizing-based), the general idea is sufficiently similar # to ASHRAE 90.1 (e.g., heating and/or cooling based, no distinction for # INDIRECTLY conditioned spaces like plenums). # # SEMI-HEATED spaces are also a defined NECB term, but again the distinction # is based on desired/intended design space setpoint temperatures - not # system sizing criteria. No further treatment is implemented here to # distinguish SEMI-HEATED from CONDITIONED spaces. # # The single NECB criterion distinguishing UNCONDITIONED ENCLOSED spaces # (such as vestibules) from UNENCLOSED spaces (such as attics) remains the # intention to ventilate - or rather to what degree. Regardless, the methods # here are designed to process both classifications in the same way, namely by # focusing on adjacent surfaces to CONDITIONED (or SEMI-HEATED) spaces as part # of the building envelope. # In light of the above, methods here are designed without a priori knowledge # of explicit system sizing choices or access to iterative autosizing # processes. As discussed in greater detail elswhere, methods are developed to # rely on zoning info and/or "intended" temperature setpoints. # # For an OpenStudio model (OSM) in an incomplete or preliminary state, e.g. # holding fully-formed ENCLOSED spaces without thermal zoning information or # setpoint temperatures (early design stage assessments of form, porosity or # envelope), all OSM spaces will be considered CONDITIONED, presuming # setpoints of ~21°C (heating) and ~24°C (cooling). # # If ANY valid space/zone-specific temperature setpoints are found in the OSM, # spaces/zones WITHOUT valid heating or cooling setpoints are considered as # UNCONDITIONED or UNENCLOSED spaces (like attics), or INDIRECTLY CONDITIONED # spaces (like plenums), see "plenum?" method. ## # Return min & max values of a schedule (ruleset). # # @param sched [OpenStudio::Model::ScheduleRuleset] schedule # # @return [Hash] min: (Float), max: (Float) # @return [Hash] min: nil, max: nil (if invalid input) def scheduleRulesetMinMax(sched) # Largely inspired from David Goldwasser's # "schedule_ruleset_annual_min_max_value": # # github.com/NREL/openstudio-standards/blob/ # 99cf713750661fe7d2082739f251269c2dfd9140/lib/openstudio-standards/ # standards/Standards.ScheduleRuleset.rb#L124 mth = "OSut::#{__callee__}" cl = OpenStudio::Model::ScheduleRuleset res = { min: nil, max: nil } return invalid("sched", mth, 1, DBG, res) unless sched.respond_to?(NS) id = sched.nameString return mismatch(id, sched, cl, mth, DBG, res) unless sched.is_a?(cl) profiles = [] profiles << sched.defaultDaySchedule sched.scheduleRules.each { |rule| profiles << rule.daySchedule } profiles.each do |profile| id = profile.nameString profile.values.each do |val| unless val.is_a?(Numeric) log(WRN, "Skipping non-numeric profile values in '#{id}' (#{mth})") next end res[:min] = val unless res[:min] res[:min] = val if res[:min] > val res[:max] = val unless res[:max] res[:max] = val if res[:max] < val end end valid = res[:min] && res[:max] log(ERR, "Invalid MIN/MAX in '#{id}' (#{mth})") unless valid res end ## # Return min & max values of a schedule (constant). # # @param sched [OpenStudio::Model::ScheduleConstant] schedule # # @return [Hash] min: (Float), max: (Float) # @return [Hash] min: nil, max: nil (if invalid input) def scheduleConstantMinMax(sched) # Largely inspired from David Goldwasser's # "schedule_constant_annual_min_max_value": # # github.com/NREL/openstudio-standards/blob/ # 99cf713750661fe7d2082739f251269c2dfd9140/lib/openstudio-standards/ # standards/Standards.ScheduleConstant.rb#L21 mth = "OSut::#{__callee__}" cl = OpenStudio::Model::ScheduleConstant res = { min: nil, max: nil } return invalid("sched", mth, 1, DBG, res) unless sched.respond_to?(NS) id = sched.nameString return mismatch(id, sched, cl, mth, DBG, res) unless sched.is_a?(cl) unless sched.value.is_a?(Numeric) return mismatch("'#{id}' value", sched.value, Numeric, mth, ERR, res) else res[:min] = sched.value res[:max] = sched.value end res end ## # Return min & max values of a schedule (compact). # # @param sched [OpenStudio::Model::ScheduleCompact] schedule # # @return [Hash] min: (Float), max: (Float) # @return [Hash] min: nil, max: nil (if invalid input) def scheduleCompactMinMax(sched) # Largely inspired from Andrew Parker's # "schedule_compact_annual_min_max_value": # # github.com/NREL/openstudio-standards/blob/ # 99cf713750661fe7d2082739f251269c2dfd9140/lib/openstudio-standards/ # standards/Standards.ScheduleCompact.rb#L8 mth = "OSut::#{__callee__}" cl = OpenStudio::Model::ScheduleCompact vals = [] prev_str = "" res = { min: nil, max: nil } return invalid("sched", mth, 1, DBG, res) unless sched.respond_to?(NS) id = sched.nameString return mismatch(id, sched, cl, mth, DBG, res) unless sched.is_a?(cl) sched.extensibleGroups.each do |eg| if prev_str.include?("until") vals << eg.getDouble(0).get unless eg.getDouble(0).empty? end str = eg.getString(0) prev_str = str.get.downcase unless str.empty? end return empty("'#{id}' values", mth, ERR, res) if vals.empty? if vals.min.is_a?(Numeric) && vals.max.is_a?(Numeric) res[:min] = vals.min res[:max] = vals.max else log(ERR, "Non-numeric values in '#{id}' (#{mth})") end res end ## # Return min & max values for schedule (interval). # # @param sched [OpenStudio::Model::ScheduleInterval] schedule # # @return [Hash] min: (Float), max: (Float) # @return [Hash] min: nil, max: nil (if invalid input) def scheduleIntervalMinMax(sched) mth = "OSut::#{__callee__}" cl = OpenStudio::Model::ScheduleInterval vals = [] prev_str = "" res = { min: nil, max: nil } return invalid("sched", mth, 1, DBG, res) unless sched.respond_to?(NS) id = sched.nameString return mismatch(id, sched, cl, mth, DBG, res) unless sched.is_a?(cl) vals = sched.timeSeries.values if vals.min.is_a?(Numeric) && vals.max.is_a?(Numeric) res[:min] = vals.min res[:max] = vals.max else log(ERR, "Non-numeric values in '#{id}' (#{mth})") end res end ## # Return max zone heating temperature schedule setpoint [°C] and whether # zone has active dual setpoint thermostat. # # @param zone [OpenStudio::Model::ThermalZone] a thermal zone # # @return [Hash] spt: (Float), dual: (Bool) # @return [Hash] spt: nil, dual: false (if invalid input) def maxHeatScheduledSetpoint(zone) # Largely inspired from Parker & Marrec's "thermal_zone_heated?" procedure. # The solution here is a tad more relaxed to encompass SEMI-HEATED zones as # per Canadian NECB criteria (basically any space with at least 10 W/m2 of # installed heating equipement, i.e. below freezing in Canada). # # github.com/NREL/openstudio-standards/blob/ # 58964222d25783e9da4ae292e375fb0d5c902aa5/lib/openstudio-standards/ # standards/Standards.ThermalZone.rb#L910 mth = "OSut::#{__callee__}" cl = OpenStudio::Model::ThermalZone res = { spt: nil, dual: false } return invalid("zone", mth, 1, DBG, res) unless zone.respond_to?(NS) id = zone.nameString return mismatch(id, zone, cl, mth, DBG, res) unless zone.is_a?(cl) # Zone radiant heating? Get schedule from radiant system. zone.equipment.each do |equip| sched = nil unless equip.to_ZoneHVACHighTemperatureRadiant.empty? equip = equip.to_ZoneHVACHighTemperatureRadiant.get unless equip.heatingSetpointTemperatureSchedule.empty? sched = equip.heatingSetpointTemperatureSchedule.get end end unless equip.to_ZoneHVACLowTemperatureRadiantElectric.empty? equip = equip.to_ZoneHVACLowTemperatureRadiantElectric.get unless equip.heatingSetpointTemperatureSchedule.empty? sched = equip.heatingSetpointTemperatureSchedule.get end end unless equip.to_ZoneHVACLowTempRadiantConstFlow.empty? equip = equip.to_ZoneHVACLowTempRadiantConstFlow.get coil = equip.heatingCoil unless coil.to_CoilHeatingLowTempRadiantConstFlow.empty? coil = coil.to_CoilHeatingLowTempRadiantConstFlow.get unless coil.heatingHighControlTemperatureSchedule.empty? sched = c.heatingHighControlTemperatureSchedule.get end end end unless equip.to_ZoneHVACLowTempRadiantVarFlow.empty? equip = equip.to_ZoneHVACLowTempRadiantVarFlow.get coil = equip.heatingCoil unless coil.to_CoilHeatingLowTempRadiantVarFlow.empty? coil = coil.to_CoilHeatingLowTempRadiantVarFlow.get unless coil.heatingControlTemperatureSchedule.empty? sched = coil.heatingControlTemperatureSchedule.get end end end next unless sched unless sched.to_ScheduleRuleset.empty? sched = sched.to_ScheduleRuleset.get max = scheduleRulesetMinMax(sched)[:max] if max res[:spt] = max unless res[:spt] res[:spt] = max if res[:spt] < max end end unless sched.to_ScheduleConstant.empty? sched = sched.to_ScheduleConstant.get max = scheduleConstantMinMax(sched)[:max] if max res[:spt] = max unless res[:spt] res[:spt] = max if res[:spt] < max end end unless sched.to_ScheduleCompact.empty? sched = sched.to_ScheduleCompact.get max = scheduleCompactMinMax(sched)[:max] if max res[:spt] = max unless res[:spt] res[:spt] = max if res[:spt] < max end end end return res if res[:spt] return res if zone.thermostat.empty? tstat = zone.thermostat.get unless tstat.to_ThermostatSetpointDualSetpoint.empty? && tstat.to_ZoneControlThermostatStagedDualSetpoint.empty? res[:dual] = true unless tstat.to_ThermostatSetpointDualSetpoint.empty? tstat = tstat.to_ThermostatSetpointDualSetpoint.get else tstat = tstat.to_ZoneControlThermostatStagedDualSetpoint.get end unless tstat.heatingSetpointTemperatureSchedule.empty? sched = tstat.heatingSetpointTemperatureSchedule.get unless sched.to_ScheduleRuleset.empty? sched = sched.to_ScheduleRuleset.get max = scheduleRulesetMinMax(sched)[:max] if max res[:spt] = max unless res[:spt] res[:spt] = max if res[:spt] < max end dd = sched.winterDesignDaySchedule unless dd.values.empty? res[:spt] = dd.values.max unless res[:spt] res[:spt] = dd.values.max if res[:spt] < dd.values.max end end unless sched.to_ScheduleConstant.empty? sched = sched.to_ScheduleConstant.get max = scheduleConstantMinMax(sched)[:max] if max res[:spt] = max unless res[:spt] res[:spt] = max if res[:spt] < max end end unless sched.to_ScheduleCompact.empty? sched = sched.to_ScheduleCompact.get max = scheduleCompactMinMax(sched)[:max] if max res[:spt] = max unless res[:spt] res[:spt] = max if res[:spt] < max end end unless sched.to_ScheduleYear.empty? sched = sched.to_ScheduleYear.get sched.getScheduleWeeks.each do |week| next if week.winterDesignDaySchedule.empty? dd = week.winterDesignDaySchedule.get next unless dd.values.empty? res[:spt] = dd.values.max unless res[:spt] res[:spt] = dd.values.max if res[:spt] < dd.values.max end end end end res end ## # Validate if model has zones with valid heating temperature setpoints. # # @param model [OpenStudio::Model::Model] a model # # @return [Bool] true if valid heating temperature setpoints # @return [Bool] false if invalid input def heatingTemperatureSetpoints?(model) mth = "OSut::#{__callee__}" cl = OpenStudio::Model::Model return invalid("model", mth, 1, DBG, false) unless model return mismatch("model", model, cl, mth, DBG, false) unless model.is_a?(cl) model.getThermalZones.each do |zone| return true if maxHeatScheduledSetpoint(zone)[:spt] end false end ## # Return min zone cooling temperature schedule setpoint [°C] and whether # zone has active dual setpoint thermostat. # # @param zone [OpenStudio::Model::ThermalZone] a thermal zone # # @return [Hash] spt: (Float), dual: (Bool) # @return [Hash] spt: nil, dual: false (if invalid input) def minCoolScheduledSetpoint(zone) # Largely inspired from Parker & Marrec's "thermal_zone_cooled?" procedure. # # github.com/NREL/openstudio-standards/blob/ # 99cf713750661fe7d2082739f251269c2dfd9140/lib/openstudio-standards/ # standards/Standards.ThermalZone.rb#L1058 mth = "OSut::#{__callee__}" cl = OpenStudio::Model::ThermalZone res = { spt: nil, dual: false } return invalid("zone", mth, 1, DBG, res) unless zone.respond_to?(NS) id = zone.nameString return mismatch(id, zone, cl, mth, DBG, res) unless zone.is_a?(cl) # Zone radiant cooling? Get schedule from radiant system. zone.equipment.each do |equip| sched = nil unless equip.to_ZoneHVACLowTempRadiantConstFlow.empty? equip = equip.to_ZoneHVACLowTempRadiantConstFlow.get coil = equip.coolingCoil unless coil.to_CoilCoolingLowTempRadiantConstFlow.empty? coil = coil.to_CoilCoolingLowTempRadiantConstFlow.get unless coil.coolingLowControlTemperatureSchedule.empty? sched = coil.coolingLowControlTemperatureSchedule.get end end end unless equip.to_ZoneHVACLowTempRadiantVarFlow.empty? equip = equip.to_ZoneHVACLowTempRadiantVarFlow.get coil = equip.coolingCoil unless coil.to_CoilCoolingLowTempRadiantVarFlow.empty? coil = coil.to_CoilCoolingLowTempRadiantVarFlow.get unless coil.coolingControlTemperatureSchedule.empty? sched = coil.coolingControlTemperatureSchedule.get end end end next unless sched unless sched.to_ScheduleRuleset.empty? sched = sched.to_ScheduleRuleset.get min = scheduleRulesetMinMax(sched)[:min] if min res[:spt] = min unless res[:spt] res[:spt] = min if res[:spt] > min end end unless sched.to_ScheduleConstant.empty? sched = sched.to_ScheduleConstant.get min = scheduleConstantMinMax(sched)[:min] if min res[:spt] = min unless res[:spt] res[:spt] = min if res[:spt] > min end end unless sched.to_ScheduleCompact.empty? sched = sched.to_ScheduleCompact.get min = scheduleCompactMinMax(sched)[:min] if min res[:spt] = min unless res[:spt] res[:spt] = min if res[:spt] > min end end end return res if res[:spt] return res if zone.thermostat.empty? tstat = zone.thermostat.get unless tstat.to_ThermostatSetpointDualSetpoint.empty? && tstat.to_ZoneControlThermostatStagedDualSetpoint.empty? res[:dual] = true unless tstat.to_ThermostatSetpointDualSetpoint.empty? tstat = tstat.to_ThermostatSetpointDualSetpoint.get else tstat = tstat.to_ZoneControlThermostatStagedDualSetpoint.get end unless tstat.coolingSetpointTemperatureSchedule.empty? sched = tstat.coolingSetpointTemperatureSchedule.get unless sched.to_ScheduleRuleset.empty? sched = sched.to_ScheduleRuleset.get min = scheduleRulesetMinMax(sched)[:min] if min res[:spt] = min unless res[:spt] res[:spt] = min if res[:spt] > min end dd = sched.summerDesignDaySchedule unless dd.values.empty? res[:spt] = dd.values.min unless res[:spt] res[:spt] = dd.values.min if res[:spt] > dd.values.min end end unless sched.to_ScheduleConstant.empty? sched = sched.to_ScheduleConstant.get min = scheduleConstantMinMax(sched)[:min] if min res[:spt] = min unless res[:spt] res[:spt] = min if res[:spt] > min end end unless sched.to_ScheduleCompact.empty? sched = sched.to_ScheduleCompact.get min = scheduleCompactMinMax(sched)[:min] if min res[:spt] = min unless res[:spt] res[:spt] = min if res[:spt] > min end end unless sched.to_ScheduleYear.empty? sched = sched.to_ScheduleYear.get sched.getScheduleWeeks.each do |week| next if week.summerDesignDaySchedule.empty? dd = week.summerDesignDaySchedule.get next unless dd.values.empty? res[:spt] = dd.values.min unless res[:spt] res[:spt] = dd.values.min if res[:spt] > dd.values.min end end end end res end ## # Validate if model has zones with valid cooling temperature setpoints. # # @param model [OpenStudio::Model::Model] a model # # @return [Bool] true if valid cooling temperature setpoints # @return [Bool] false if invalid input def coolingTemperatureSetpoints?(model) mth = "OSut::#{__callee__}" cl = OpenStudio::Model::Model return invalid("model", mth, 1, DBG, false) unless model return mismatch("model", model, cl, mth, DBG, false) unless model.is_a?(cl) model.getThermalZones.each do |zone| return true if minCoolScheduledSetpoint(zone)[:spt] end false end ## # Validate if model has zones with HVAC air loops. # # @param model [OpenStudio::Model::Model] a model # # @return [Bool] true if model has one or more HVAC air loops # @return [Bool] false if invalid input def airLoopsHVAC?(model) mth = "OSut::#{__callee__}" cl = OpenStudio::Model::Model return invalid("model", mth, 1, DBG, false) unless model return mismatch("model", model, cl, mth, DBG, false) unless model.is_a?(cl) model.getThermalZones.each do |zone| next if zone.canBePlenum return true unless zone.airLoopHVACs.empty? return true if zone.isPlenum end false end ## # Validate whether space should be processed as a plenum. # # @param space [OpenStudio::Model::Space] a space # @param loops [Bool] true if model has airLoopHVAC object(s) # @param setpoints [Bool] true if model has valid temperature setpoints # # @return [Bool] true if should be tagged as plenum # @return [Bool] false if invalid input def plenum?(space, loops, setpoints) # Largely inspired from NREL's "space_plenum?" procedure: # # github.com/NREL/openstudio-standards/blob/ # 58964222d25783e9da4ae292e375fb0d5c902aa5/lib/openstudio-standards/ # standards/Standards.Space.rb#L1384 # A space may be tagged as a plenum if: # # CASE A: its zone's "isPlenum" == true (SDK method) for a fully-developed # OpenStudio model (complete with HVAC air loops); # # CASE B: it's excluded from building's total floor area yet linked to a # zone holding an "inactive" thermostat (i.e., can't extract # valid setpoints); # # CASE C: it has a spacetype whose name holds "plenum", or a spacetype with # a 'standards spacetype' holding "plenum" (case insensitive); OR # # CASE D: its name string holds "plenum" (also case insensitive). mth = "OSut::#{__callee__}" cl = OpenStudio::Model::Space return invalid("space", mth, 1, DBG, false) unless space.respond_to?(NS) id = space.nameString return mismatch(id, space, cl, mth, DBG, false) unless space.is_a?(cl) valid = loops == true || loops == false return invalid("loops", mth, 2, DBG, false) unless valid valid = setpoints == true || setpoints == false return invalid("setpoints", mth, 3, DBG, false) unless valid unless space.thermalZone.empty? zone = space.thermalZone.get return true if zone.isPlenum && loops # CASE A if setpoints heating = maxHeatScheduledSetpoint(zone) cooling = minCoolScheduledSetpoint(zone) return false if heating[:spt] || cooling[:spt] # directly conditioned unless space.partofTotalFloorArea return true if heating[:dual] || cooling[:dual] # CASE B end end end unless space.spaceType.empty? type = space.spaceType.get return true if type.nameString.downcase.include?("plenum") # CASE C unless type.standardsSpaceType.empty? type = type.standardsSpaceType.get return true if type.downcase.include?("plenum") # CASE C end end return true if space.nameString.downcase.include?("plenum") false end ## # Generate an HVAC availability schedule. # # @param model [OpenStudio::Model::Model] a model # @param avl [String] seasonal availability choice (optional, default "ON") # # @return [OpenStudio::Model::Schedule] HVAC availability sched # @return [nil] if invalid input def availabilitySchedule(model, avl = "") mth = "OSut::#{__callee__}" cl = OpenStudio::Model::Model return invalid("model", mth, 1) unless model return mismatch("model", model, cl, mth) unless model.is_a?(cl) # Either fetch availability ScheduleTypeLimits object, or create one. limits = nil model.getScheduleTypeLimitss.each do |l| break if limits next if l.lowerLimitValue.empty? next if l.upperLimitValue.empty? next if l.numericType.empty? next unless l.lowerLimitValue.get.to_i == 0 next unless l.upperLimitValue.get.to_i == 1 next unless l.numericType.get.downcase == "discrete" next unless l.unitType.downcase == "availability" next unless l.nameString.downcase == "hvac operation scheduletypelimits" limits = l end unless limits limits = OpenStudio::Model::ScheduleTypeLimits.new(model) limits.setName("HVAC Operation ScheduleTypeLimits") limits.setLowerLimitValue(0) limits.setUpperLimitValue(1) limits.setNumericType("Discrete") limits.setUnitType("Availability") end time = OpenStudio::Time.new(0,24) secs = time.totalSeconds on = OpenStudio::Model::ScheduleDay.new(model, 1) off = OpenStudio::Model::ScheduleDay.new(model, 0) # Seasonal availability start/end dates. year = model.yearDescription return empty("yearDescription", mth, ERR) if year.empty? year = year.get may01 = year.makeDate(OpenStudio::MonthOfYear.new("May"), 1) oct31 = year.makeDate(OpenStudio::MonthOfYear.new("Oct"), 31) case avl.downcase when "winter" # available from November 1 to April 30 (6 months) val = 1 sch = off nom = "WINTER Availability SchedRuleset" dft = "WINTER Availability dftDaySched" tag = "May-Oct WINTER Availability SchedRule" day = "May-Oct WINTER SchedRule Day" when "summer" # available from May 1 to October 31 (6 months) val = 0 sch = on nom = "SUMMER Availability SchedRuleset" dft = "SUMMER Availability dftDaySched" tag = "May-Oct SUMMER Availability SchedRule" day = "May-Oct SUMMER SchedRule Day" when "off" # never available val = 0 sch = on nom = "OFF Availability SchedRuleset" dft = "OFF Availability dftDaySched" tag = "" day = "" else # always available val = 1 sch = on nom = "ON Availability SchedRuleset" dft = "ON Availability dftDaySched" tag = "" day = "" end # Fetch existing schedule. ok = true schedule = model.getScheduleByName(nom) unless schedule.empty? schedule = schedule.get.to_ScheduleRuleset unless schedule.empty? schedule = schedule.get default = schedule.defaultDaySchedule ok = ok && default.nameString == dft ok = ok && default.times.size == 1 ok = ok && default.values.size == 1 ok = ok && default.times.first == time ok = ok && default.values.first == val rules = schedule.scheduleRules ok = ok && (rules.size == 0 || rules.size == 1) if rules.size == 1 rule = rules.first ok = ok && rule.nameString == tag ok = ok && !rule.startDate.empty? ok = ok && !rule.endDate.empty? ok = ok && rule.startDate.get == may01 ok = ok && rule.endDate.get == oct31 ok = ok && rule.applyAllDays d = rule.daySchedule ok = ok && d.nameString == day ok = ok && d.times.size == 1 ok = ok && d.values.size == 1 ok = ok && d.times.first.totalSeconds == secs ok = ok && d.values.first.to_i != val end return schedule if ok end end schedule = OpenStudio::Model::ScheduleRuleset.new(model) schedule.setName(nom) unless schedule.setScheduleTypeLimits(limits) log(ERR, "'#{nom}': Can't set schedule type limits (#{mth})") return nil end unless schedule.defaultDaySchedule.addValue(time, val) log(ERR, "'#{nom}': Can't set default day schedule (#{mth})") return nil end schedule.defaultDaySchedule.setName(dft) unless tag.empty? rule = OpenStudio::Model::ScheduleRule.new(schedule, sch) rule.setName(tag) unless rule.setStartDate(may01) log(ERR, "'#{tag}': Can't set start date (#{mth})") return nil end unless rule.setEndDate(oct31) log(ERR, "'#{tag}': Can't set end date (#{mth})") return nil end unless rule.setApplyAllDays(true) log(ERR, "'#{tag}': Can't apply to all days (#{mth})") return nil end rule.daySchedule.setName(day) end schedule end ## # Validate if default construction set holds a base ground construction. # # @param set [OpenStudio::Model::DefaultConstructionSet] a default set # @param base [OpensStudio::Model::ConstructionBase] a construction base # @param ground [Bool] true if ground-facing surface # @param exterior [Bool] true if exterior-facing surface # @param type [String] a surface type # # @return [Bool] true if default construction set holds construction # @return [Bool] false if invalid input def holdsConstruction?(set, base, ground = false, exterior = false, type = "") mth = "OSut::#{__callee__}" cl1 = OpenStudio::Model::DefaultConstructionSet cl2 = OpenStudio::Model::ConstructionBase return invalid("set", mth, 1, DBG, false) unless set.respond_to?(NS) id = set.nameString return mismatch(id, set, cl1, mth, DBG, false) unless set.is_a?(cl1) return invalid("base", mth, 2, DBG, false) unless base.respond_to?(NS) id = base.nameString return mismatch(id, base, cl2, mth, DBG, false) unless base.is_a?(cl2) valid = ground == true || ground == false return invalid("ground", mth, 3, DBG, false) unless valid valid = exterior == true || exterior == false return invalid("exterior", mth, 4, DBG, false) unless valid typ = type.to_s.downcase valid = typ == "floor" || typ == "wall" || typ == "roofceiling" return invalid("surface type", mth, 5, DBG, false) unless valid constructions = nil if ground unless set.defaultGroundContactSurfaceConstructions.empty? constructions = set.defaultGroundContactSurfaceConstructions.get end elsif exterior unless set.defaultExteriorSurfaceConstructions.empty? constructions = set.defaultExteriorSurfaceConstructions.get end else unless set.defaultInteriorSurfaceConstructions.empty? constructions = set.defaultInteriorSurfaceConstructions.get end end return false unless constructions case typ when "roofceiling" unless constructions.roofCeilingConstruction.empty? construction = constructions.roofCeilingConstruction.get return true if construction == base end when "floor" unless constructions.floorConstruction.empty? construction = constructions.floorConstruction.get return true if construction == base end else unless constructions.wallConstruction.empty? construction = constructions.wallConstruction.get return true if construction == base end end false end ## # Return a surface's default construction set. # # @param model [OpenStudio::Model::Model] a model # @param s [OpenStudio::Model::Surface] a surface # # @return [OpenStudio::Model::DefaultConstructionSet] default set # @return [nil] if invalid input def defaultConstructionSet(model, s) mth = "OSut::#{__callee__}" cl1 = OpenStudio::Model::Model cl2 = OpenStudio::Model::Surface return invalid("model", mth, 1) unless model return mismatch("model", model, cl1, mth) unless model.is_a?(cl1) return invalid("s", mth, 2) unless s.respond_to?(NS) id = s.nameString return mismatch(id, s, cl2, mth) unless s.is_a?(cl2) unless s.isConstructionDefaulted log(ERR, "'#{id}' construction not defaulted (#{mth})") return nil end return empty("'#{id}' construction", mth, ERR) if s.construction.empty? base = s.construction.get return empty("'#{id}' space", mth, ERR) if s.space.empty? space = s.space.get type = s.surfaceType ground = false exterior = false if s.isGroundSurface ground = true elsif s.outsideBoundaryCondition.downcase == "outdoors" exterior = true end unless space.defaultConstructionSet.empty? set = space.defaultConstructionSet.get return set if holdsConstruction?(set, base, ground, exterior, type) end unless space.spaceType.empty? spacetype = space.spaceType.get unless spacetype.defaultConstructionSet.empty? set = spacetype.defaultConstructionSet.get return set if holdsConstruction?(set, base, ground, exterior, type) end end unless space.buildingStory.empty? story = space.buildingStory.get unless story.defaultConstructionSet.empty? set = story.defaultConstructionSet.get return set if holdsConstruction?(set, base, ground, exterior, type) end end building = model.getBuilding unless building.defaultConstructionSet.empty? set = building.defaultConstructionSet.get return set if holdsConstruction?(set, base, ground, exterior, type) end nil end ## # Validate if every material in a layered construction is standard & opaque. # # @param lc [OpenStudio::LayeredConstruction] a layered construction # # @return [Bool] true if all layers are valid # @return [Bool] false if invalid input def standardOpaqueLayers?(lc) mth = "OSut::#{__callee__}" cl = OpenStudio::Model::LayeredConstruction return invalid("lc", mth, 1, DBG, false) unless lc.respond_to?(NS) return mismatch(lc.nameString, lc, cl, mth, DBG, false) unless lc.is_a?(cl) lc.layers.each { |m| return false if m.to_StandardOpaqueMaterial.empty? } true end ## # Total (standard opaque) layered construction thickness (in m). # # @param lc [OpenStudio::LayeredConstruction] a layered construction # # @return [Double] total layered construction thickness # @return [Double] 0 if invalid input def thickness(lc) mth = "OSut::#{__callee__}" cl = OpenStudio::Model::LayeredConstruction return invalid("lc", mth, 1, DBG, 0) unless lc.respond_to?(NS) id = lc.nameString return mismatch(id, lc, cl, mth, DBG, 0) unless lc.is_a?(cl) unless standardOpaqueLayers?(lc) log(ERR, "'#{id}' holds non-StandardOpaqueMaterial(s) (#{mth})") return 0 end thickness = 0.0 lc.layers.each { |m| thickness += m.thickness } thickness end ## # Return total air film resistance for fenestration. # # @param usi [Float] a fenestrated construction's U-factor (W/m2•K) # # @return [Float] total air film resistance in m2•K/W (0.1216 if errors) def glazingAirFilmRSi(usi = 5.85) # The sum of thermal resistances of calculated exterior and interior film # coefficients under standard winter conditions are taken from: # # https://bigladdersoftware.com/epx/docs/9-6/engineering-reference/ # window-calculation-module.html#simple-window-model # # These remain acceptable approximations for flat windows, yet likely # unsuitable for subsurfaces with curved or projecting shapes like domed # skylights. The solution here is considered an adequate fix for reporting, # awaiting eventual OpenStudio (and EnergyPlus) upgrades to report NFRC 100 # (or ISO) air film resistances under standard winter conditions. # # For U-factors above 8.0 W/m2•K (or invalid input), the function returns # 0.1216 m2•K/W, which corresponds to a construction with a single glass # layer thickness of 2mm & k = ~0.6 W/m.K. # # The EnergyPlus Engineering calculations were designed for vertical windows # - not horizontal, slanted or domed surfaces - use with caution. mth = "OSut::#{__callee__}" cl = Numeric return invalid("usi", mth, 1, DBG, 0.1216) unless usi return mismatch("usi", usi, cl, mth, DBG, 0.1216) unless usi.is_a?(cl) return invalid("usi", mth, 1, WRN, 0.1216) if usi > 8.0 rsi = 1 / (0.025342 * usi + 29.163853) # exterior film, next interior film return rsi + 1 / (0.359073 * Math.log(usi) + 6.949915) if usi < 5.85 return rsi + 1 / (1.788041 * usi - 2.886625) end ## # Return a construction's 'standard calc' thermal resistance (with air films). # # @param lc [OpenStudio::Model::LayeredConstruction] a layered construction # @param film [Float] thermal resistance of surface air films (m2•K/W) # @param t [Float] gas temperature (°C) (optional) # # @return [Float] calculated RSi at standard conditions (0 if error) def rsi(lc, film, t = 0.0) # This is adapted from BTAP's Material Module's "get_conductance" (P. Lopez) # # https://github.com/NREL/OpenStudio-Prototype-Buildings/blob/ # c3d5021d8b7aef43e560544699fb5c559e6b721d/lib/btap/measures/ # btap_equest_converter/envelope.rb#L122 mth = "OSut::#{__callee__}" cl1 = OpenStudio::Model::LayeredConstruction cl2 = Numeric return invalid("lc", mth, 1, DBG, 0) unless lc.respond_to?(NS) id = lc.nameString return mismatch(id, lc, cl1, mth, DBG, 0) unless lc.is_a?(cl1) return invalid("film", mth, 2, DBG, 0) unless film return invalid("temperature", mth, 3, DBG, 0) unless t return mismatch("film", film, cl2, mth, DBG, 0) unless film.is_a?(cl2) return mismatch("temperature", t, cl2, mth, DBG, 0) unless t.is_a?(cl2) tt = t + 273.0 # °C to K return negative("temp K", mth, DBG, 0) if tt < 0 return negative("film", mth, DBG, 0) if film < 0 rsi = film lc.layers.each do |m| # Fenestration materials first (ignoring shades, screens, etc.) unless m.to_SimpleGlazing.empty? return 1 / m.to_SimpleGlazing.get.uFactor # no need to loop end unless m.to_StandardGlazing.empty? rsi += m.to_StandardGlazing.get.thermalResistance end unless m.to_RefractionExtinctionGlazing.empty? rsi += m.to_RefractionExtinctionGlazing.get.thermalResistance end unless m.to_Gas.empty? rsi += m.to_Gas.get.getThermalResistance(tt) end unless m.to_GasMixture.empty? rsi += m.to_GasMixture.get.getThermalResistance(tt) end # Opaque materials next. unless m.to_StandardOpaqueMaterial.empty? rsi += m.to_StandardOpaqueMaterial.get.thermalResistance end unless m.to_MasslessOpaqueMaterial.empty? rsi += m.to_MasslessOpaqueMaterial.get.thermalResistance end unless m.to_RoofVegetation.empty? rsi += m.to_RoofVegetation.get.thermalResistance end unless m.to_AirGap.empty? rsi += m.to_AirGap.get.thermalResistance end end rsi end ## # Identify a layered construction's (opaque) insulating layer. The method # returns a 3-keyed hash ... :index (insulating layer index within layered # construction), :type (standard: or massless: material type), and # :r (material thermal resistance in m2•K/W). # # @param lc [OpenStudio::Model::LayeredConstruction] a layered construction # # @return [Hash] index: (Integer), type: (:standard or :massless), r: (Float) # @return [Hash] index: nil, type: nil, r: 0 (if invalid input) def insulatingLayer(lc) mth = "OSut::#{__callee__}" cl = OpenStudio::Model::LayeredConstruction res = { index: nil, type: nil, r: 0.0 } i = 0 # iterator return invalid("lc", mth, 1, DBG, res) unless lc.respond_to?(NS) id = lc.nameString return mismatch(id, lc, cl1, mth, DBG, res) unless lc.is_a?(cl) lc.layers.each do |m| unless m.to_MasslessOpaqueMaterial.empty? m = m.to_MasslessOpaqueMaterial.get if m.thermalResistance < 0.001 || m.thermalResistance < res[:r] i += 1 next else res[:r] = m.thermalResistance res[:index] = i res[:type] = :massless end end unless m.to_StandardOpaqueMaterial.empty? m = m.to_StandardOpaqueMaterial.get k = m.thermalConductivity d = m.thickness if d < 0.003 || k > 3.0 || d / k < res[:r] i += 1 next else res[:r] = d / k res[:index] = i res[:type] = :standard end end i += 1 end res end ## # Return OpenStudio site/space transformation & rotation angle [0,2PI) rads. # # @param model [OpenStudio::Model::Model] a model # @param group [OpenStudio::Model::PlanarSurfaceGroup] a group # # @return [Hash] t: (OpenStudio::Transformation), r: Float # @return [Hash] t: nil, r: nil (if invalid input) def transforms(model, group) mth = "OSut::#{__callee__}" cl1 = OpenStudio::Model::Model cl2 = OpenStudio::Model::PlanarSurfaceGroup res = { t: nil, r: nil } return invalid("model", mth, 1, DBG, res) unless model return mismatch("model", model, cl1, mth, DBG, res) unless model.is_a?(cl1) return invalid("group", mth, 2, DBG, res) unless group.respond_to?(NS) id = group.nameString return mismatch(id, group, cl2, mth, DBG, res) unless group.is_a?(cl2) res[:t] = group.siteTransformation res[:r] = group.directionofRelativeNorth + model.getBuilding.northAxis res end ## # Flatten OpenStudio 3D points vs Z-axis (Z=0). # # @param pts [Array] an OpenStudio Point3D array/vector # # @return [Array] flattened OpenStudio 3D points def flatZ(pts) mth = "OSut::#{__callee__}" cl1 = OpenStudio::Point3dVector cl2 = OpenStudio::Point3d v = OpenStudio::Point3dVector.new return invalid("points", mth, 1, DBG, v) unless pts valid = pts.is_a?(cl1) || pts.is_a?(Array) return mismatch("points", pts, cl1, mth, DBG, v) unless valid pts.each { |pt| mismatch("pt", pt, cl2, mth, ERR, v) unless pt.is_a?(cl2) } pts.each { |pt| v << OpenStudio::Point3d.new(pt.x, pt.y, 0) } v end ## # Validate whether 1st OpenStudio convex polygon fits in 2nd convex polygon. # # @param p1 [OpenStudio::Point3dVector] or Point3D array of polygon #1 # @param p2 [OpenStudio::Point3dVector] or Point3D array of polygon #2 # @param id1 [String] polygon #1 identifier (optional) # @param id2 [String] polygon #2 identifier (optional) # # @return [Bool] true if 1st polygon fits entirely within the 2nd polygon # @return [Bool] false if invalid input def fits?(p1, p2, id1 = "", id2 = "") mth = "OSut::#{__callee__}" cl1 = OpenStudio::Point3dVector cl2 = OpenStudio::Point3d a = false i1 = id1.to_s i2 = id2.to_s i1 = "poly1" if i1.empty? i2 = "poly2" if i2.empty? return invalid(i1, mth, 1, DBG, a) unless p1 valid = p1.is_a?(cl1) || p1.is_a?(Array) return mismatch(i1, p1, cl1, mth, DBG, a) unless valid return empty(i1, mth, ERR, a) if p1.empty? return invalid(i2, mth, 2, DBG, a) unless p2 valid = p2.is_a?(cl1) || p2.is_a?(Array) return mismatch(i2, p2, cl1, mth, DBG, a) unless valid return empty(i2, mth, ERR, a) if p2.empty? p1.each { |v| return mismatch(i1, v, cl2, mth, ERR, a) unless v.is_a?(cl2) } p2.each { |v| return mismatch(i2, v, cl2, mth, ERR, a) unless v.is_a?(cl2) } ft = OpenStudio::Transformation::alignFace(p1).inverse ft_p1 = flatZ( (ft * p1).reverse ) return false if ft_p1.empty? area1 = OpenStudio::getArea(ft_p1) return empty(i1, mth, ERR, a) if area1.empty? area1 = area1.get ft_p2 = flatZ( (ft * p2).reverse ) return false if ft_p2.empty? area2 = OpenStudio::getArea(ft_p2) return empty(i2, mth, ERR, a) if area2.empty? area2 = area2.get union = OpenStudio::join(ft_p1, ft_p2, TOL2) return false if union.empty? union = union.get area = OpenStudio::getArea(union) return empty("union", mth, ERR, a) if area.empty? area = area.get return false if area < TOL return true if (area - area2).abs < TOL return false if (area - area2).abs > TOL true end ## # Validate whether an OpenStudio polygon overlaps another. # # @param p1 [OpenStudio::Point3dVector] or Point3D array of polygon #1 # @param p2 [OpenStudio::Point3dVector] or Point3D array of polygon #2 # @param id1 [String] polygon #1 identifier (optional) # @param id2 [String] polygon #2 identifier (optional) # # @return Returns true if polygons overlaps (or either fits into the other) # @return [Bool] false if invalid input def overlaps?(p1, p2, id1 = "", id2 = "") mth = "OSut::#{__callee__}" cl1 = OpenStudio::Point3dVector cl2 = OpenStudio::Point3d a = false i1 = id1.to_s i2 = id2.to_s i1 = "poly1" if i1.empty? i2 = "poly2" if i2.empty? return invalid(i1, mth, 1, DBG, a) unless p1 valid = p1.is_a?(cl1) || p1.is_a?(Array) return mismatch(i1, p1, cl1, mth, DBG, a) unless valid return empty(i1, mth, ERR, a) if p1.empty? return invalid(i2, mth, 2, DBG, a) unless p2 valid = p2.is_a?(cl1) || p2.is_a?(Array) return mismatch(i2, p2, cl1, mth, DBG, a) unless valid return empty(i2, mth, ERR, a) if p2.empty? p1.each { |v| return mismatch(i1, v, cl2, mth, ERR, a) unless v.is_a?(cl2) } p2.each { |v| return mismatch(i2, v, cl2, mth, ERR, a) unless v.is_a?(cl2) } ft = OpenStudio::Transformation::alignFace(p1).inverse ft_p1 = flatZ( (ft * p1).reverse ) return false if ft_p1.empty? area1 = OpenStudio::getArea(ft_p1) return empty(i1, mth, ERR, a) if area1.empty? area1 = area1.get ft_p2 = flatZ( (ft * p2).reverse ) return false if ft_p2.empty? area2 = OpenStudio::getArea(ft_p2) return empty(i2, mth, ERR, a) if area2.empty? area2 = area2.get union = OpenStudio::join(ft_p1, ft_p2, TOL2) return false if union.empty? union = union.get area = OpenStudio::getArea(union) return empty("union", mth, ERR, a) if area.empty? area = area.get return false if area < TOL true end end