# ******************************************************************************* # OpenStudio(R), Copyright (c) 2008-2021, Alliance for Sustainable Energy, LLC. # 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 any contributors # may be used to endorse or promote products derived from this software without # specific prior written permission from the respective party. # # (4) Other than as required in clauses (1) and (2), distributions in any form # of modifications or other derivative works may not use the "OpenStudio" # trademark, "OS", "os", or any other confusingly similar designation without # specific prior written permission from Alliance for Sustainable Energy, LLC. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDER(S) AND ANY 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(S), ANY CONTRIBUTORS, THE # UNITED STATES GOVERNMENT, OR THE UNITED STATES DEPARTMENT OF ENERGY, NOR ANY OF # THEIR EMPLOYEES, 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 'erb' # start the measure class VentilationQAQC < OpenStudio::Measure::ReportingMeasure # define the name that a user will see, this method may be deprecated as # the display name in PAT comes from the name field in measure.xml def name return 'Ventilation Report' end def energyPlusOutputRequests(runner, user_arguments) super(runner, user_arguments) result = OpenStudio::IdfObjectVector.new if !runner.validateUserArguments(arguments, user_arguments) return result end ventilation = OpenStudio::IdfObject.load('Output:Variable,,Zone Mechanical Ventilation Current Density Volume Flow Rate,Hourly;').get result << ventilation ach = OpenStudio::IdfObject.load('Output:Variable,,Zone Infiltration Air Change Rate,Hourly;').get result << ach people = OpenStudio::IdfObject.load('Output:Variable,,Zone People Occupant Count,Hourly;').get result << people return result end # define the arguments that the user will input # # note - there is no 'model' argument provided here. This may cause an issue. # def arguments(model = nil) args = OpenStudio::Measure::OSArgumentVector.new # Future functionality # zone_titles = [] # model.getThermalZones.each do |thermalZone| # zone_name = thermalZone.name.empty? ? thermalZone.name.get : '' # zone_titles.push( zone_name ) # end # Choice list of measure_zones measure_zones = ['All Zones'] measure_zone = OpenStudio::Measure::OSArgument.makeChoiceArgument('measure_zone', measure_zones, measure_zones, true) measure_zone.setDefaultValue('All Zones') measure_zone.setDisplayName('Pick a Zone (or all Zones)') args << measure_zone return args end # define what happens when the measure is run def run(runner, user_arguments) super(runner, user_arguments) # use the built-in error checking if !runner.validateUserArguments(arguments, user_arguments) return false end # get the last model and sql file model = runner.lastOpenStudioModel if model.empty? runner.registerError('Cannot find last model.') return false end model = model.get @sqlFile = runner.lastEnergyPlusSqlFile if @sqlFile.empty? runner.registerError('Cannot find last sql file.') return false end @sqlFile = @sqlFile.get model.setSqlFile(@sqlFile) # Get the weather file (as opposed to design day) run period annEnvPd = nil @sqlFile.availableEnvPeriods.each do |envPd| envType = @sqlFile.environmentType(envPd) if !envType.empty? if envType.get == 'WeatherRunPeriod'.to_EnvironmentType annEnvPd = envPd end else puts('Could not get weather file info') end end # binding.pry # put data into variables, these are available in the local scope binding zoneCollection = [] spaceCollection = [] annualGraphData = [] warnings = [] model.getThermalZones.sort.each do |thermalZone| zone_name = !thermalZone.name.empty? ? thermalZone.name.get : '' puts("Zone:#{zone_name}") # Get the hourly ventilation in cfm puts('Get Ventilation') zone_mechanical_ventilation_vals = getTimeSeries('Zone Mechanical Ventilation Current Density Volume Flow Rate', zone_name.upcase, annEnvPd, 'Hourly', runner) if zone_mechanical_ventilation_vals zone_mechanical_ventilation_vals.map! { |v| v * 2118.882 } max_ventilation = zone_mechanical_ventilation_vals.max || 0 # max of an empty array is nil, so use 0 for max ventilation in this case else max_ventilation = 0 end puts('Get Infiltration') zone_infiltration_vals = getTimeSeries('Zone Infiltration Air Change Rate', zone_name.upcase, annEnvPd, 'Hourly', runner) zone_occupant_vals = getTimeSeries('Zone People Occupant Count', zone_name.upcase, annEnvPd, 'Hourly', runner) zone_occupant_max = !zone_occupant_vals.nil? ? zone_occupant_vals.max : 0 # stop here if zone_occupant_max is 0 is don't divide by 0 and catch zone_occupant_vals when nil before .map if zone_occupant_max == 0 runner.registerInfo("Skipping #{zone_name}, can't noramlize occupancy with max of 0.") else zone_occupant_normalized = zone_occupant_vals.map { |v| v / zone_occupant_max } unoccupied = 0 lightly_occupied = 0 coordinated = zone_occupant_normalized.zip(zone_mechanical_ventilation_vals) coordinated.each do |vals| normalized_occupancy = vals[0] zone_mech_vent = vals[1] if normalized_occupancy == 0 && zone_mech_vent > 0 unoccupied += 1 end if normalized_occupancy < 0.05 && zone_mech_vent > 20 lightly_occupied += 1 end end puts "Unoccupied: #{unoccupied}, Lightly Occupied: #{lightly_occupied}" if unoccupied > 10 warnings.push("Thermal Zone #{zone_name} appears to have mechanical ventilation during periods when the zone is unoccupied, resulting in potentially unnecessary ventilation energy. This occurs #{unoccupied} hours per run period. Please ensure this is a correct representation of the modeling scenario. Minimum fraction OA schedules may need adjusting.") end if lightly_occupied > 1500 warnings.push("Thermal Zone #{zone_name} appears to have mechanical ventilation during periods when the zone is lightly occupied, resulting in potentially unnecessary ventilation energy. This occurs #{lightly_occupied} hours per run period. Please ensure this is a correct representation of the modeling scenario.") end end times = getTimesForSeries('Zone Infiltration Air Change Rate', zone_name.upcase, annEnvPd, 'Hourly', runner) if !times.nil? js_date_times = times.map { |t| to_JSTime(t) } # Create an array of arrays [timestamp, zone_mechanical_ventilation_vals, zone_infiltration_vals] if zone_infiltration_vals hourly_vals = js_date_times.zip(zone_infiltration_vals) else hourly_vals = nil end # Add the hourly load data to JSON for the report.html graph = {} graph['title'] = "#{zone_name} - Hourly Infiltration" graph['xaxislabel'] = 'Time' graph['yaxislabel'] = 'Infiltration (ACH)' graph['labels'] = ['Date', 'Infiltration (ACH)'] graph['colors'] = ['#FF5050', '#0066FF'] graph['timeseries'] = hourly_vals # This measure requires ruby 2.0.0 to create the JSON for the report graph if RUBY_VERSION >= '2.0.0' annualGraphData << graph end end zoneMetrics = {} zoneMetrics[:zoneWeightedCFM] = 0 thermalZone.spaces.each do |space| spaceMetrics = {} spaceMetrics[:name] = !space.name.empty? ? space.name.get : '' spaceMetrics[:isPartOfTotalFloorArea] = space.partofTotalFloorArea spaceMetrics[:floorAreaM2] = space.floorArea spaceMetrics[:volumeM3] = space.volume spaceMetrics[:peoplePerFloorArea] = space.peoplePerFloorArea # spaceMetrics[:exteriorAreaM2] = space.exteriorArea spaceMetrics[:calculatedPeople] = space.floorArea * space.peoplePerFloorArea if !space.designSpecificationOutdoorAir.empty? spec = space.designSpecificationOutdoorAir.get # spaceMetrics[:specMethod] = spec.outdoorAirMethod spaceMetrics[:specOutdoorAirFlowperPerson] = spec.outdoorAirFlowperPerson spaceMetrics[:specOutdoorAirFlowperFloorArea] = spec.outdoorAirFlowperFloorArea spaceMetrics[:specOutdoorAirFlowRate] = spec.outdoorAirFlowRate spaceMetrics[:specOutdoorAirFlowAirChangesperHour] = spec.outdoorAirFlowAirChangesperHour # Outdoor Air Method # Outdoor Air Flow per Person {m3/s-person} # Outdoor Air Flow per Floor Area {m3/s-m2} # Outdoor Air Flow Rate {m3/s} # Outdoor Air Flow Air Changes per Hour {1/hr} # Outdoor Air Flow Rate Fraction Schedule Name outdoorAirFlow = calculateOutdoorAirFlow(spaceMetrics, spec) if spaceMetrics[:calculatedPeople] > 0 spaceMetrics[:outsideAirPerPerson] = OpenStudio.convert(outdoorAirFlow, 'm^3/s', 'cfm').get / spaceMetrics[:calculatedPeople] else spaceMetrics[:outsideAirPerPerson] = 0 end else spaceMetrics[:outsideAirPerPerson] = 0 end i = 0 spaceMetrics[:designFlowRates] = {} space.spaceInfiltrationDesignFlowRates.each do |designFlowRate| spaceMetrics[:designFlowRates][i] = {} spaceMetrics[:designFlowRates][i][:calcMethod] = designFlowRate.designFlowRateCalculationMethod spaceMetrics[:designFlowRates][i][:designFlowRate] = !designFlowRate.designFlowRate.empty? ? designFlowRate.designFlowRate.get : nil spaceMetrics[:designFlowRates][i][:flowPerSpaceFloorArea] = !designFlowRate.flowperSpaceFloorArea.empty? ? designFlowRate.flowperSpaceFloorArea.get : nil spaceMetrics[:designFlowRates][i][:flowPerExteriorSurfaceArea] = !designFlowRate.flowperExteriorSurfaceArea.empty? ? designFlowRate.flowperExteriorSurfaceArea.get : nil spaceMetrics[:designFlowRates][i][:airChangesperHour] = !designFlowRate.airChangesperHour.empty? ? designFlowRate.airChangesperHour.get : nil i += 1 end spaceMetrics[:airChangesPerHour] = space.infiltrationDesignAirChangesPerHour i = 0 spaceMetrics[:effectiveLeakageAreas] = {} space.spaceInfiltrationEffectiveLeakageAreas.each do |effectiveLeakageArea| spaceMetrics[:effectiveLeakageAreas][i] = {} spaceMetrics[:effectiveLeakageAreas][i][:leakageArea] = effectiveLeakageArea.effectiveAirLeakageArea i += 1 end designFlow = spaceMetrics[:outsideAirPerPerson] ? spaceMetrics[:outsideAirPerPerson] * spaceMetrics[:calculatedPeople] : 0 spaceWeight = spaceMetrics[:floorAreaM2] / thermalZone.floorArea spaceMetrics[:spaceWeightedCFM] = spaceWeight * designFlow spaceCollection.push(spaceMetrics) zoneMetrics[:zoneWeightedCFM] = zoneMetrics[:zoneWeightedCFM] + spaceMetrics[:spaceWeightedCFM] end if zoneMetrics[:zoneWeightedCFM] / 2 > max_ventilation warnings.push("Thermal Zone #{zone_name} appears to have excessive outside air assignments. Check the number of Design Specification Outdoor Air Objects associated with this zone.") end zoneCollection.push(zoneMetrics) end spaceCollection.sort! { |a, b| a[:name].downcase <=> b[:name].downcase } output = "Measure Name = #{name}" # Convert the graph data to JSON # This measure requires ruby 2.0.0 to create the JSON for the report graph if RUBY_VERSION >= '2.0.0' require 'json' annualGraphData = annualGraphData.to_json else runner.registerInfo("This Measure needs Ruby 2.0.0 to generate timeseries graphs on the report. You have Ruby #{RUBY_VERSION}. OpenStudio 1.4.2 and higher user Ruby 2.0.0.") end web_asset_path = OpenStudio.getSharedResourcesPath / OpenStudio::Path.new('web_assets') html_in = getResourceFileData('report.html.in') # configure template with variable values renderer = ERB.new(html_in) html_out = renderer.result(binding) writeResourceFileData('report.html', html_out) # closing the sql file @sqlFile.close # reporting final condition runner.registerFinalCondition('Goodbye.') @outData = { spaceCollection: spaceCollection, zoneCollection: zoneCollection, warnings: warnings } return true end attr_reader :outData def getResourceFileData(fileName) data_in_path = "#{File.dirname(__FILE__)}/resources/#{fileName}" if !File.exist?(data_in_path) data_in_path = "#{File.dirname(__FILE__)}/#{fileName}" end html_in = '' File.open(data_in_path, 'r') do |file| html_in = file.read end html_in end def writeResourceFileData(fileName, data) File.open("./#{fileName}", 'w') do |file| file << data # make sure data is written to the disk one way or the other begin file.fsync rescue StandardError file.flush end end end def getTimeSeries(name, index, envperiod, rate, runner) series = @sqlFile.timeSeries(envperiod, rate, name, index) if series.empty? runner.registerWarning("No data found for '#{name}' '#{index}'") return nil else series = series.get end series_collection = series.values series_vals = [] for i in 0..(series_collection.size - 1) series_vals << series_collection[i] end series_vals end def getTimesForSeries(name, index, envperiod, rate, runner) series = @sqlFile.timeSeries(envperiod, rate, name, index) if series.empty? runner.registerWarning("No data found for '#{name}' '#{index}'") return nil else series = series.get end series.dateTimes end def calculateOutdoorAirFlow(spaceMetrics, spec) # Calculate airflows for all methods. Airflows are in native units (m3/s) flows = [ spec.outdoorAirFlowperPerson * spaceMetrics[:calculatedPeople], spec.outdoorAirFlowperFloorArea * spaceMetrics[:floorAreaM2], spec.outdoorAirFlowAirChangesperHour * spaceMetrics[:volumeM3] / 3600, spec.outdoorAirFlowRate ] # Depending on the outdoorAirMethod chosen, we return either the sum or maximum of the above flows if spec.outdoorAirMethod == 'Sum' return flows.inject(0) { |sum, i| sum + i } end if spec.outdoorAirMethod == 'Maximum' return flows.max end end def getFlowPerPerson(spaceMetrics) out = 0 if !spaceMetrics[:specOutdoorAirFlowperPerson].nil? out = spaceMetrics[:specOutdoorAirFlowperPerson] elsif !spaceMetrics[:specOutdoorAirFlowperFloorArea].nil? if spaceMetrics[:peoplePerFloorArea] != 0 out = spec.getOutdoorAirFlowperFloorArea.value * (1 / spaceMetrics[:peoplePerFloorArea]) end elsif !spaceMetrics[:specOutdoorAirFlowRate].nil? if spaceMetrics[:calculatedPeople] != 0 out = spec.outdoorAirFlowRate / spaceMetrics[:calculatedPeople] end elsif !spaceMetrics[:specOutdoorAirFlowAirChangesperHour].nil? if spaceMetrics[:calculatedPeople] != 0 out = spec.getOutdoorAirFlowAirChangesperHour.value / 60 * spaceMetrics[:volumeM3] / spaceMetrics[:calculatedPeople] end end end def getAirChangesPerHour(spaceMetrics) out = 0 i = 0 # translate existing metrics into airChangesPerHour and sum for all designFlowRate structures secondsPerHourPerVolume = 3600 / spaceMetrics[:volumeM3] spaceMetrics[:designFlowRates].each do |flowRate| curr = 0 if !spaceMetrics[:designFlowRates][i][:designFlowRate].nil? curr = spaceMetrics[:designFlowRates][i][:designFlowRate] * secondsPerHourPerVolume elsif !spaceMetrics[:designFlowRates][i][:flowPerSpaceFloorArea].nil? curr = spaceMetrics[:designFlowRates][i][:flowPerSpaceFloorArea] * spaceMetrics[:floorAreaM2] * secondsPerHourPerVolume elsif !spaceMetrics[:designFlowRates][i][:flowPerExteriorSurfaceArea].nil? curr = spaceMetrics[:designFlowRates][i][:flowPerExteriorSurfaceArea] * spaceMetrics[:exteriorAreaM2] * secondsPerHourPerVolume elsif !spaceMetrics[:designFlowRates][i][:airChangesperHour].nil? curr = spaceMetrics[:designFlowRates][i][:airChangesperHour] end out += curr i += 1 end return out end # Method to translate from OpenStudio's time formatting # to Javascript time formatting # OpenStudio time # 2009-May-14 00:10:00 Raw string # Javascript time # 2009/07/12 12:34:56 def to_JSTime(os_time) js_time = os_time.to_s # Replace the '-' with '/' js_time = js_time.tr('-', '/') # Replace month abbreviations with numbers js_time = js_time.gsub('Jan', '01') js_time = js_time.gsub('Feb', '02') js_time = js_time.gsub('Mar', '03') js_time = js_time.gsub('Apr', '04') js_time = js_time.gsub('May', '05') js_time = js_time.gsub('Jun', '06') js_time = js_time.gsub('Jul', '07') js_time = js_time.gsub('Aug', '08') js_time = js_time.gsub('Sep', '09') js_time = js_time.gsub('Oct', '10') js_time = js_time.gsub('Nov', '11') js_time = js_time.gsub('Dec', '12') return js_time end end # this allows the measure to be use by the application VentilationQAQC.new.registerWithApplication