lib/osut/utils.rb in osut-0.3.0 vs lib/osut/utils.rb in osut-0.4.0

- old
+ new

@@ -29,27 +29,1098 @@ # 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) + # DEBUG for devs; WARN/ERROR for users (bad OS input), see OSlg + extend OSlg - TOL = 0.01 - TOL2 = TOL * TOL - 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 - NS = "nameString" # OpenStudio IdfObject nameString method - HEAD = 2.032 # standard 80" door - SILL = 0.762 # standard 30" window sill + TOL = 0.01 # default distance tolerance (m) + TOL2 = TOL * TOL # default area tolerance (m2) + DBG = OSlg::DEBUG # see github.com/rd2/oslg + INF = OSlg::INFO # see github.com/rd2/oslg + WRN = OSlg::WARN # see github.com/rd2/oslg + ERR = OSlg::ERROR # see github.com/rd2/oslg + FTL = OSlg::FATAL # see github.com/rd2/oslg + NS = "nameString" # OpenStudio object identifier method - # This first set of utilities (~750 lines) help distinguishing spaces that - # are directly vs indirectly CONDITIONED, vs SEMI-HEATED. The solution here + HEAD = 2.032 # standard 80" door + SILL = 0.762 # standard 30" window sill + + # General surface orientations (see facets method) + SIDZ = [:bottom, # e.g. ground-facing, exposed floros + :top, # e.g. roof/ceiling + :north, # NORTH + :east, # EAST + :south, # SOUTH + :west # WEST + ].freeze + + # This first set of utilities support OpenStudio materials, constructions, + # construction sets, etc. If relying on default StandardOpaqueMaterial: + # - roughness (rgh) : "Smooth" + # - thickness : 0.1 m + # - thermal conductivity (k ) : 0.1 W/m.K + # - density (rho) : 0.1 kg/m3 + # - specific heat (cp ) : 1400.0 J/kg.K + # + # https://s3.amazonaws.com/openstudio-sdk-documentation/cpp/ + # OpenStudio-3.6.1-doc/model/html/ + # classopenstudio_1_1model_1_1_standard_opaque_material.html + # + # ... apart from surface roughness, rarely would these material properties be + # suitable - and are therefore explicitely set below. On roughness: + # - "Very Rough" : stucco + # - "Rough" : brick + # - "Medium Rough" : concrete + # - "Medium Smooth" : clear pine + # - "Smooth" : smooth plaster + # - "Very Smooth" : glass + + # thermal mass categories (e.g. exterior cladding, interior finish, framing) + @@mass = [ + :none, # token for 'no user selection', resort to defaults + :light, # e.g. 16mm drywall interior + :medium, # e.g. 100mm brick cladding + :heavy # e.g. 200mm poured concrete + ].freeze + + # basic materials (StandardOpaqueMaterials only) + @@mats = { + sand: {}, + concrete: {}, + brick: {}, + cladding: {}, # e.g. lightweight cladding over furring + sheathing: {}, # e.g. plywood + polyiso: {}, # e.g. polyisocyanurate panel (or similar) + cellulose: {}, # e.g. blown, dry/stabilized fiber + mineral: {}, # e.g. semi-rigid rock wool insulation + drywall: {}, + door: {} # single composite material (45mm insulated steel door) + }.freeze + + # default inside+outside air film resistances (m2.K/W) + @@film = { + shading: 0.000, # NA + partition: 0.000, + wall: 0.150, + roof: 0.140, + floor: 0.190, + basement: 0.120, + slab: 0.160, + door: 0.150, + window: 0.150, # ignored if SimpleGlazingMaterial + skylight: 0.140 # ignored if SimpleGlazingMaterial + }.freeze + + # default (~1980s) envelope Uo (W/m2•K), based on surface type + @@uo = { + shading: 0.000, # N/A + partition: 0.000, # N/A + wall: 0.384, # rated Ro ~14.8 hr•ft2F/Btu + roof: 0.327, # rated Ro ~17.6 hr•ft2F/Btu + floor: 0.317, # rated Ro ~17.9 hr•ft2F/Btu (exposed floor) + basement: 0.000, # uninsulated + slab: 0.000, # uninsulated + door: 1.800, # insulated, unglazed steel door (single layer) + window: 2.800, # e.g. patio doors (simple glazing) + skylight: 3.500 # all skylight technologies + }.freeze + + # Standard opaque materials, taken from a variety of sources (e.g. energy + # codes, NREL's BCL). Material identifiers are symbols, e.g.: + # - :brick + # - :sand + # - :concrete + # + # Material properties remain largely constant between projects. What does + # tend to vary (between projects) are thicknesses. Actual OpenStudio opaque + # material objects can be (re)set in more than one way by class methods. + # In genConstruction, OpenStudio object identifiers are later suffixed with + # actual material thicknesses, in mm, e.g.: + # - "concrete200" : 200mm concrete slab + # - "drywall13" : 1/2" gypsum board + # - "drywall16" : 5/8" gypsum board + # + # Surface absorptances are also defaulted in OpenStudio: + # - thermal, long-wave (thm) : 90% + # - solar (sol) : 70% + # - visible (vis) : 70% + # + # These can also be explicitly set, here (e.g. a redundant 'sand' example): + @@mats[:sand ][:rgh] = "Rough" + @@mats[:sand ][:k ] = 1.290 + @@mats[:sand ][:rho] = 2240.000 + @@mats[:sand ][:cp ] = 830.000 + @@mats[:sand ][:thm] = 0.900 + @@mats[:sand ][:sol] = 0.700 + @@mats[:sand ][:vis] = 0.700 + + @@mats[:concrete ][:rgh] = "MediumRough" + @@mats[:concrete ][:k ] = 1.730 + @@mats[:concrete ][:rho] = 2240.000 + @@mats[:concrete ][:cp ] = 830.000 + + @@mats[:brick ][:rgh] = "Rough" + @@mats[:brick ][:k ] = 0.675 + @@mats[:brick ][:rho] = 1600.000 + @@mats[:brick ][:cp ] = 790.000 + + @@mats[:cladding ][:rgh] = "MediumSmooth" + @@mats[:cladding ][:k ] = 0.115 + @@mats[:cladding ][:rho] = 540.000 + @@mats[:cladding ][:cp ] = 1200.000 + + @@mats[:sheathing][:k ] = 0.160 + @@mats[:sheathing][:rho] = 545.000 + @@mats[:sheathing][:cp ] = 1210.000 + + @@mats[:polyiso ][:k ] = 0.025 + @@mats[:polyiso ][:rho] = 25.000 + @@mats[:polyiso ][:cp ] = 1590.000 + + @@mats[:cellulose][:rgh] = "VeryRough" + @@mats[:cellulose][:k ] = 0.050 + @@mats[:cellulose][:rho] = 80.000 + @@mats[:cellulose][:cp ] = 835.000 + + @@mats[:mineral ][:k ] = 0.050 + @@mats[:mineral ][:rho] = 19.000 + @@mats[:mineral ][:cp ] = 960.000 + + @@mats[:drywall ][:k ] = 0.160 + @@mats[:drywall ][:rho] = 785.000 + @@mats[:drywall ][:cp ] = 1090.000 + + @@mats[:door ][:rgh] = "MediumSmooth" + @@mats[:door ][:k ] = 0.080 + @@mats[:door ][:rho] = 600.000 + @@mats[:door ][:cp ] = 1000.000 + + ## + # Generates an OpenStudio multilayered construction; materials if needed. + # + # @param model [OpenStudio::Model::Model] a model + # @param [Hash] specs OpenStudio construction specifications + # @option specs [#to_s] :id ("") construction identifier + # @option specs [Symbol] :type (:wall), see @@uo + # @option specs [Numeric] :uo clear-field Uo, in W/m2.K, see @@uo + # @option specs [Symbol] :clad (:light) exterior cladding, see @@mass + # @option specs [Symbol] :frame (:light) assembly framing, see @@mass + # @option specs [Symbol] :finish (:light) interior finishing, see @@mass + # + # @return [OpenStudio::Model::Construction] generated construction + # @return [nil] if invalid inputs (see logs) + def genConstruction(model = nil, specs = {}) + mth = "OSut::#{__callee__}" + cl1 = OpenStudio::Model::Model + cl2 = Hash + return mismatch("model", model, cl1, mth) unless model.is_a?(cl1) + return mismatch("specs", specs, cl2, mth) unless specs.is_a?(cl2) + + specs[:id ] = "" unless specs.key?(:id ) + specs[:type] = :wall unless specs.key?(:type) + chk = @@uo.keys.include?(specs[:type]) + return invalid("surface type", mth, 2, ERR) unless chk + + id = trim(specs[:id]) + id = "OSut|CON|#{specs[:type]}" if id.empty? + specs[:uo] = @@uo[ specs[:type] ] unless specs.key?(:uo) + u = specs[:uo] + return mismatch("#{id} Uo", u, Numeric, mth) unless u.is_a?(Numeric) + return invalid("#{id} Uo (> 5.678)", mth, 2, ERR) if u > 5.678 + return negative("#{id} Uo" , mth, ERR) if u < 0 + + # Optional specs. Log/reset if invalid. + specs[:clad ] = :light unless specs.key?(:clad ) # exterior + specs[:frame ] = :light unless specs.key?(:frame ) + specs[:finish] = :light unless specs.key?(:finish) # interior + log(WRN, "Reset to light cladding") unless @@mass.include?(specs[:clad ]) + log(WRN, "Reset to light framing" ) unless @@mass.include?(specs[:frame ]) + log(WRN, "Reset to light finish" ) unless @@mass.include?(specs[:finish]) + specs[:clad ] = :light unless @@mass.include?(specs[:clad ]) + specs[:frame ] = :light unless @@mass.include?(specs[:frame ]) + specs[:finish] = :light unless @@mass.include?(specs[:finish]) + + film = @@film[ specs[:type] ] + + # Layered assembly (max 4 layers): + # - cladding + # - intermediate sheathing + # - composite insulating/framing + # - interior finish + a = {clad: {}, sheath: {}, compo: {}, finish: {}, glazing: {}} + + case specs[:type] + when :shading + mt = :sheathing + d = 0.015 + a[:compo][:mat] = @@mats[mt] + a[:compo][:d ] = d + a[:compo][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}" + when :partition + d = 0.015 + mt = :drywall + a[:clad][:mat] = @@mats[mt] + a[:clad][:d ] = d + a[:clad][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}" + + mt = :sheathing + a[:compo][:mat] = @@mats[mt] + a[:compo][:d ] = d + a[:compo][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}" + + mt = :drywall + a[:finish][:mat] = @@mats[mt] + a[:finish][:d ] = d + a[:finish][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}" + when :wall + unless specs[:clad] == :none + mt = :cladding + mt = :brick if specs[:clad] == :medium + mt = :concrete if specs[:clad] == :heavy + d = 0.100 + d = 0.015 if specs[:clad] == :light + a[:clad][:mat] = @@mats[mt] + a[:clad][:d ] = d + a[:clad][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}" + end + + mt = :drywall + mt = :polyiso if specs[:frame] == :medium + mt = :mineral if specs[:frame] == :heavy + d = 0.100 + d = 0.015 if specs[:frame] == :light + a[:sheath][:mat] = @@mats[mt] + a[:sheath][:d ] = d + a[:sheath][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}" + + mt = :concrete + mt = :mineral if specs[:frame] == :light + d = 0.100 + d = 0.200 if specs[:frame] == :heavy + a[:compo][:mat] = @@mats[mt] + a[:compo][:d ] = d + a[:compo][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}" + + unless specs[:finish] == :none + mt = :concrete + mt = :drywall if specs[:finish] == :light + d = 0.015 + d = 0.100 if specs[:finish] == :medium + d = 0.200 if specs[:finish] == :heavy + a[:finish][:mat] = @@mats[mt] + a[:finish][:d ] = d + a[:finish][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}" + end + when :roof + unless specs[:clad] == :none + mt = :concrete + mt = :cladding if specs[:clad] == :light + d = 0.015 + d = 0.100 if specs[:clad] == :medium # e.g. terrace + d = 0.200 if specs[:clad] == :heavy # e.g. parking garage + a[:clad][:mat] = @@mats[mt] + a[:clad][:d ] = d + a[:clad][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}" + + mt = :sheathing + d = 0.015 + a[:sheath][:mat] = @@mats[mt] + a[:sheath][:d ] = d + a[:sheath][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}" + end + + mt = :cellulose + mt = :polyiso if specs[:frame] == :medium + mt = :mineral if specs[:frame] == :heavy + d = 0.100 + a[:compo][:mat] = @@mats[mt] + a[:compo][:d ] = d + a[:compo][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}" + + unless specs[:finish] == :none + mt = :concrete + mt = :drywall if specs[:finish] == :light + d = 0.015 + d = 0.100 if specs[:finish] == :medium # proxy for steel decking + d = 0.200 if specs[:finish] == :heavy + a[:finish][:mat] = @@mats[mt] + a[:finish][:d ] = d + a[:finish][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}" + end + when :floor # exposed + unless specs[:clad] == :none + mt = :cladding + d = 0.015 + a[:clad][:mat] = @@mats[mt] + a[:clad][:d ] = d + a[:clad][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}" + + mt = :sheathing + d = 0.015 + a[:sheath][:mat] = @@mats[mt] + a[:sheath][:d ] = d + a[:sheath][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}" + end + + mt = :cellulose + mt = :polyiso if specs[:frame] == :medium + mt = :mineral if specs[:frame] == :heavy + d = 0.100 # possibly an insulating layer to reset + a[:compo][:mat] = @@mats[mt] + a[:compo][:d ] = d + a[:compo][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}" + + unless specs[:finish] == :none + mt = :concrete + mt = :sheathing if specs[:finish] == :light + d = 0.015 + d = 0.100 if specs[:finish] == :medium + d = 0.200 if specs[:finish] == :heavy + a[:finish][:mat] = @@mats[mt] + a[:finish][:d ] = d + a[:finish][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}" + end + when :slab # basement slab or slab-on-grade + mt = :sand + d = 0.100 + a[:clad][:mat] = @@mats[mt] + a[:clad][:d ] = d + a[:clad][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}" + + unless specs[:frame] == :none + mt = :polyiso + d = 0.025 + a[:sheath][:mat] = @@mats[mt] + a[:sheath][:d ] = d + a[:sheath][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}" + end + + mt = :concrete + d = 0.100 + d = 0.200 if specs[:frame] == :heavy + a[:compo][:mat] = @@mats[mt] + a[:compo][:d ] = d + a[:compo][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}" + + unless specs[:finish] == :none + mt = :sheathing + d = 0.015 + a[:finish][:mat] = @@mats[mt] + a[:finish][:d ] = d + a[:finish][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}" + end + when :basement # wall + unless specs[:clad] == :none + mt = :concrete + mt = :sheathing if specs[:clad] == :light + d = 0.100 + d = 0.015 if specs[:clad] == :light + a[:clad][:mat] = @@mats[mt] + a[:clad][:d ] = d + a[:clad][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}" + + mt = :polyiso + d = 0.025 + a[:sheath][:mat] = @@mats[mt] + a[:sheath][:d ] = d + a[:sheath][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}" + + mt = :concrete + d = 0.200 + a[:compo][:mat] = @@mats[mt] + a[:compo][:d ] = d + a[:compo][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}" + else + mt = :concrete + d = 0.200 + a[:sheath][:mat] = @@mats[mt] + a[:sheath][:d ] = d + a[:sheath][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}" + + unless specs[:finish] == :none + mt = :mineral + d = 0.075 + a[:compo][:mat] = @@mats[mt] + a[:compo][:d ] = d + a[:compo][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}" + + mt = :drywall + d = 0.015 + a[:finish][:mat] = @@mats[mt] + a[:finish][:d ] = d + a[:finish][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}" + end + end + when :door # opaque + # 45mm insulated (composite) steel door. + mt = :door + d = 0.045 + + a[:compo ][:mat ] = @@mats[mt] + a[:compo ][:d ] = d + a[:compo ][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}" + when :window # e.g. patio doors (simple glazing) + # SimpleGlazingMaterial. + a[:glazing][:u ] = specs[:uo ] + a[:glazing][:shgc] = 0.450 + a[:glazing][:shgc] = specs[:shgc] if specs.key?(:shgc) + a[:glazing][:id ] = "OSut|window" + a[:glazing][:id ] += "|U#{format('%.1f', a[:glazing][:u])}" + a[:glazing][:id ] += "|SHGC#{format('%d', a[:glazing][:shgc]*100)}" + when :skylight + # SimpleGlazingMaterial. + a[:glazing][:u ] = specs[:uo ] + a[:glazing][:shgc] = 0.450 + a[:glazing][:shgc] = specs[:shgc] if specs.key?(:shgc) + a[:glazing][:id ] = "OSut|skylight" + a[:glazing][:id ] += "|U#{format('%.1f', a[:glazing][:u])}" + a[:glazing][:id ] += "|SHGC#{format('%d', a[:glazing][:shgc]*100)}" + end + + # Initiate layers. + glazed = true + glazed = false if a[:glazing].empty? + layers = OpenStudio::Model::OpaqueMaterialVector.new unless glazed + layers = OpenStudio::Model::FenestrationMaterialVector.new if glazed + + if glazed + u = a[:glazing][:u ] + shgc = a[:glazing][:shgc] + lyr = model.getSimpleGlazingByName(a[:glazing][:id]) + + if lyr.empty? + lyr = OpenStudio::Model::SimpleGlazing.new(model, u, shgc) + lyr.setName(a[:glazing][:id]) + else + lyr = lyr.get + end + + layers << lyr + else + # Loop through each layer spec, and generate construction. + a.each do |i, l| + next if l.empty? + + lyr = model.getStandardOpaqueMaterialByName(l[:id]) + + if lyr.empty? + lyr = OpenStudio::Model::StandardOpaqueMaterial.new(model) + lyr.setName(l[:id]) + lyr.setThickness(l[:d]) + lyr.setRoughness( l[:mat][:rgh]) if l[:mat].key?(:rgh) + lyr.setConductivity( l[:mat][:k ]) if l[:mat].key?(:k ) + lyr.setDensity( l[:mat][:rho]) if l[:mat].key?(:rho) + lyr.setSpecificHeat( l[:mat][:cp ]) if l[:mat].key?(:cp ) + lyr.setThermalAbsorptance(l[:mat][:thm]) if l[:mat].key?(:thm) + lyr.setSolarAbsorptance( l[:mat][:sol]) if l[:mat].key?(:sol) + lyr.setVisibleAbsorptance(l[:mat][:vis]) if l[:mat].key?(:vis) + else + lyr = lyr.get + end + + layers << lyr + end + end + + c = OpenStudio::Model::Construction.new(layers) + c.setName(id) + + # Adjust insulating layer thickness or conductivity to match requested Uo. + unless glazed + ro = 0 + ro = 1 / specs[:uo] - @@film[ specs[:type] ] if specs[:uo] > 0 + + if specs[:type] == :door # 1x layer, adjust conductivity + layer = c.getLayer(0).to_StandardOpaqueMaterial + return invalid("#{id} standard material?", mth, 0) if layer.empty? + + layer = layer.get + k = layer.thickness / ro + layer.setConductivity(k) + elsif ro > 0 # multiple layers, adjust insulating layer thickness + lyr = insulatingLayer(c) + return invalid("#{id} construction", mth, 0) if lyr[:index].nil? + return invalid("#{id} construction", mth, 0) if lyr[:type ].nil? + return invalid("#{id} construction", mth, 0) if lyr[:r ].zero? + + index = lyr[:index] + layer = c.getLayer(index).to_StandardOpaqueMaterial + return invalid("#{id} material @#{index}", mth, 0) if layer.empty? + + layer = layer.get + k = layer.conductivity + d = (ro - rsi(c) + lyr[:r]) * k + return invalid("#{id} adjusted m", mth, 0) if d < 0.03 + + nom = "OSut|" + nom += layer.nameString.gsub(/[^a-z]/i, "").gsub("OSut", "") + nom += "|" + nom += format("%03d", d*1000)[-3..-1] + layer.setName(nom) if model.getStandardOpaqueMaterialByName(nom).empty? + layer.setThickness(d) + end + end + + c + end + + ## + # Generates a solar shade (e.g. roller, textile) for glazed OpenStudio + # SubSurfaces (v351+), controlled to minimize overheating in cooling months + # (May to October in Northern Hemisphere), when outdoor dry bulb temperature + # is above 18°C and impinging solar radiation is above 100 W/m2. + # + # @param subs [OpenStudio::Model::SubSurfaceVector] sub surfaces + # + # @return [Bool] whether successfully generated + # @return [false] if invalid input (see logs) + def genShade(subs = OpenStudio::Model::SubSurfaceVector.new) + # Filter OpenStudio warnings for ShadingControl: + # ref: https://github.com/NREL/OpenStudio/issues/4911 + str = ".*(?<!ShadingControl)$" + OpenStudio::Logger.instance.standardOutLogger.setChannelRegex(str) + + mth = "OSut::#{__callee__}" + v = OpenStudio.openStudioVersion.split(".").join.to_i + cl = OpenStudio::Model::SubSurfaceVector + return mismatch("subs ", subs, cl2, mth, DBG, false) unless subs.is_a?(cl) + return empty( "subs", mth, WRN, false) if subs.empty? + return false if v < 321 + + # Shading availability period. + mdl = subs.first.model + id = "onoff" + onoff = mdl.getScheduleTypeLimitsByName(id) + + if onoff.empty? + onoff = OpenStudio::Model::ScheduleTypeLimits.new(mdl) + onoff.setName(id) + onoff.setLowerLimitValue(0) + onoff.setUpperLimitValue(1) + onoff.setNumericType("Discrete") + onoff.setUnitType("Availability") + else + onoff = onoff.get + end + + # Shading schedule. + id = "OSut|SHADE|Ruleset" + sch = mdl.getScheduleRulesetByName(id) + + if sch.empty? + sch = OpenStudio::Model::ScheduleRuleset.new(mdl, 0) + sch.setName(id) + sch.setScheduleTypeLimits(onoff) + sch.defaultDaySchedule.setName("OSut|Shade|Ruleset|Default") + else + sch = sch.get + end + + # Summer cooling rule. + id = "OSut|SHADE|ScheduleRule" + rule = mdl.getScheduleRuleByName(id) + + if rule.empty? + may = OpenStudio::MonthOfYear.new("May") + october = OpenStudio::MonthOfYear.new("Oct") + start = OpenStudio::Date.new(may, 1) + finish = OpenStudio::Date.new(october, 31) + + rule = OpenStudio::Model::ScheduleRule.new(sch) + rule.setName(id) + rule.setStartDate(start) + rule.setEndDate(finish) + rule.setApplyAllDays(true) + rule.daySchedule.setName("OSut|Shade|Rule|Default") + rule.daySchedule.addValue(OpenStudio::Time.new(0,24,0,0), 1) + else + rule = rule.get + end + + # Shade object. + id = "OSut|Shade" + shd = mdl.getShadeByName(id) + + if shd.empty? + shd = OpenStudio::Model::Shade.new(mdl) + shd.setName(id) + else + shd = shd.get + end + + # Shading control (unique to each call). + id = "OSut|ShadingControl" + ctl = OpenStudio::Model::ShadingControl.new(shd) + ctl.setName(id) + ctl.setSchedule(sch) + ctl.setShadingControlType("OnIfHighOutdoorAirTempAndHighSolarOnWindow") + ctl.setSetpoint(18) # °C + ctl.setSetpoint2(100) # W/m2 + ctl.setMultipleSurfaceControlType("Group") + ctl.setSubSurfaces(subs) + end + + ## + # Generates an internal mass definition and instances for target spaces. + # + # @param sps [OpenStudio::Model::SpaceVector] target spaces + # @param ratio [Numeric] internal mass surface / floor areas + # + # @return [Bool] whether successfully generated + # @return [false] if invalid input (see logs) + def genMass(sps = OpenStudio::Model::SpaceVector.new, ratio = 2.0) + # This is largely adapted from OpenStudio-Standards: + # + # https://github.com/NREL/openstudio-standards/blob/ + # d332605c2f7a35039bf658bf55cad40a7bcac317/lib/openstudio-standards/ + # prototypes/common/objects/Prototype.Model.rb#L786 + mth = "OSut::#{__callee__}" + cl1 = OpenStudio::Model::SpaceVector + cl2 = Numeric + no = false + return mismatch("spaces", sps, cl1, mth, DBG, no) unless sps.is_a?(cl1) + return mismatch( "ratio", ratio, cl2, mth, DBG, no) unless ratio.is_a?(cl2) + return empty( "spaces", mth, WRN, no) if sps.empty? + return negative( "ratio", mth, ERR, no) if ratio < 0 + + # A single material. + mdl = sps.first.model + id = "OSut|MASS|Material" + mat = mdl.getOpaqueMaterialByName(id) + + if mat.empty? + mat = OpenStudio::Model::StandardOpaqueMaterial.new(mdl) + mat.setName(id) + mat.setRoughness("MediumRough") + mat.setThickness(0.15) + mat.setConductivity(1.12) + mat.setDensity(540) + mat.setSpecificHeat(1210) + mat.setThermalAbsorptance(0.9) + mat.setSolarAbsorptance(0.7) + mat.setVisibleAbsorptance(0.17) + else + mat = mat.get + end + + # A single, 1x layered construction. + id = "OSut|MASS|Construction" + con = mdl.getConstructionByName(id) + + if con.empty? + con = OpenStudio::Model::Construction.new(mdl) + con.setName(id) + layers = OpenStudio::Model::MaterialVector.new + layers << mat + con.setLayers(layers) + else + con = con.get + end + + id = "OSut|InternalMassDefinition|" + (format "%.2f", ratio) + df = mdl.getInternalMassDefinitionByName(id) + + if df.empty? + df = OpenStudio::Model::InternalMassDefinition.new(mdl) + df.setName(id) + df.setConstruction(con) + df.setSurfaceAreaperSpaceFloorArea(ratio) + else + df = df.get + end + + sps.each do |sp| + mass = OpenStudio::Model::InternalMass.new(df) + mass.setName("OSut|InternalMass|#{sp.nameString}") + mass.setSpace(sp) + end + + true + end + + ## + # Validates if a default construction set holds a base construction. + # + # @param set [OpenStudio::Model::DefaultConstructionSet] a default set + # @param bse [OpensStudio::Model::ConstructionBase] a construction base + # @param gr [Bool] if ground-facing surface + # @param ex [Bool] if exterior-facing surface + # @param tp [#to_s] a surface type + # + # @return [Bool] whether default set holds construction + # @return [false] if invalid input (see logs) + def holdsConstruction?(set = nil, bse = nil, gr = false, ex = false, tp = "") + mth = "OSut::#{__callee__}" + cl1 = OpenStudio::Model::DefaultConstructionSet + cl2 = OpenStudio::Model::ConstructionBase + ck1 = set.respond_to?(NS) + ck2 = bse.respond_to?(NS) + return invalid("set" , mth, 1, DBG, false) unless ck1 + return invalid("base", mth, 2, DBG, false) unless ck2 + + id1 = set.nameString + id2 = bse.nameString + ck1 = set.is_a?(cl1) + ck2 = bse.is_a?(cl2) + ck3 = [true, false].include?(gr) + ck4 = [true, false].include?(ex) + ck5 = tp.respond_to?(:to_s) + return mismatch(id1, set, cl1, mth, DBG, false) unless ck1 + return mismatch(id2, bse, cl2, mth, DBG, false) unless ck2 + return invalid("ground" , mth, 3, DBG, false) unless ck3 + return invalid("exterior" , mth, 4, DBG, false) unless ck4 + return invalid("surface type", mth, 5, DBG, false) unless ck5 + + type = trim(tp).downcase + ck1 = ["floor", "wall", "roofceiling"].include?(type) + return invalid("surface type", mth, 5, DBG, false) unless ck1 + + constructions = nil + + if gr + unless set.defaultGroundContactSurfaceConstructions.empty? + constructions = set.defaultGroundContactSurfaceConstructions.get + end + elsif ex + 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 type + when "roofceiling" + unless constructions.roofCeilingConstruction.empty? + construction = constructions.roofCeilingConstruction.get + return true if construction == bse + end + when "floor" + unless constructions.floorConstruction.empty? + construction = constructions.floorConstruction.get + return true if construction == bse + end + else + unless constructions.wallConstruction.empty? + construction = constructions.wallConstruction.get + return true if construction == bse + end + end + + false + end + + ## + # Returns a surface's default construction set. + # + # @param s [OpenStudio::Model::Surface] a surface + # + # @return [OpenStudio::Model::DefaultConstructionSet] default set + # @return [nil] if invalid input (see logs) + def defaultConstructionSet(s = nil) + mth = "OSut::#{__callee__}" + cl = OpenStudio::Model::Surface + return invalid("surface", mth, 1) unless s.respond_to?(NS) + + id = s.nameString + ok = s.isConstructionDefaulted + m1 = "#{id} construction not defaulted (#{mth})" + m2 = "#{id} construction" + m3 = "#{id} space" + return mismatch(id, s, cl, mth) unless s.is_a?(cl) + + log(ERR, m1) unless ok + return nil unless ok + return empty(m2, mth, ERR) if s.construction.empty? + return empty(m3, mth, ERR) if s.space.empty? + + mdl = s.model + base = s.construction.get + 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 = mdl.getBuilding + + unless building.defaultConstructionSet.empty? + set = building.defaultConstructionSet.get + return set if holdsConstruction?(set, base, ground, exterior, type) + end + + nil + end + + ## + # Validates if every material in a layered construction is standard & opaque. + # + # @param lc [OpenStudio::LayeredConstruction] a layered construction + # + # @return [Bool] whether all layers are valid + # @return [false] if invalid input (see logs) + def standardOpaqueLayers?(lc = nil) + 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 + + ## + # Returns total (standard opaque) layered construction thickness (m). + # + # @param lc [OpenStudio::LayeredConstruction] a layered construction + # + # @return [Float] construction thickness + # @return [0.0] if invalid input (see logs) + def thickness(lc = nil) + mth = "OSut::#{__callee__}" + cl = OpenStudio::Model::LayeredConstruction + return invalid("lc", mth, 1, DBG, 0.0) unless lc.respond_to?(NS) + + id = lc.nameString + return mismatch(id, lc, cl, mth, DBG, 0.0) unless lc.is_a?(cl) + + ok = standardOpaqueLayers?(lc) + log(ERR, "'#{id}' holds non-StandardOpaqueMaterial(s) (#{mth})") unless ok + return 0.0 unless ok + + thickness = 0.0 + lc.layers.each { |m| thickness += m.thickness } + + thickness + end + + ## + # Returns total air film resistance of a fenestrated construction (m2•K/W) + # + # @param usi [Numeric] a fenestrated construction's U-factor (W/m2•K) + # + # @return [Float] total air film resistances + # @return [0.1216] if invalid input (see logs) + 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 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 + return negative("usi", mth, WRN, 0.1216) if usi < 0 + return zero("usi", mth, WRN, 0.1216) if usi.abs < TOL + + 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 + + ## + # Returns a construction's 'standard calc' thermal resistance (m2•K/W), which + # includes air film resistances. It excludes insulating effects of shades, + # screens, etc. in the case of fenestrated constructions. + # + # @param lc [OpenStudio::Model::LayeredConstruction] a layered construction + # @param film [Numeric] thermal resistance of surface air films (m2•K/W) + # @param t [Numeric] gas temperature (°C) (optional) + # + # @return [Float] layered construction's thermal resistance + # @return [0.0] if invalid input (see logs) + def rsi(lc = nil, film = 0.0, t = 0.0) + # This is adapted from BTAP's Material Module "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.0) unless lc.respond_to?(NS) + + id = lc.nameString + return mismatch(id, lc, cl1, mth, DBG, 0.0) unless lc.is_a?(cl1) + return mismatch("film", film, cl2, mth, DBG, 0.0) unless film.is_a?(cl2) + return mismatch("temp K", t, cl2, mth, DBG, 0.0) unless t.is_a?(cl2) + + t += 273.0 # °C to K + return negative("temp K", mth, ERR, 0.0) if t < 0 + return negative("film", mth, ERR, 0.0) if film < 0 + + rsi = film + + lc.layers.each do |m| + # Fenestration materials first. + empty = m.to_SimpleGlazing.empty? + return 1 / m.to_SimpleGlazing.get.uFactor unless empty + + empty = m.to_StandardGlazing.empty? + rsi += m.to_StandardGlazing.get.thermalResistance unless empty + empty = m.to_RefractionExtinctionGlazing.empty? + rsi += m.to_RefractionExtinctionGlazing.get.thermalResistance unless empty + empty = m.to_Gas.empty? + rsi += m.to_Gas.get.getThermalResistance(t) unless empty + empty = m.to_GasMixture.empty? + rsi += m.to_GasMixture.get.getThermalResistance(t) unless empty + + # Opaque materials next. + empty = m.to_StandardOpaqueMaterial.empty? + rsi += m.to_StandardOpaqueMaterial.get.thermalResistance unless empty + empty = m.to_MasslessOpaqueMaterial.empty? + rsi += m.to_MasslessOpaqueMaterial.get.thermalResistance unless empty + empty = m.to_RoofVegetation.empty? + rsi += m.to_RoofVegetation.get.thermalResistance unless empty + empty = m.to_AirGap.empty? + rsi += m.to_AirGap.get.thermalResistance unless empty + end + + rsi + end + + ## + # Identifies a layered construction's (opaque) insulating layer. The method + # returns a 3-keyed hash :index, the insulating layer index [0, n layers) + # within the layered construction; :type, either :standard or :massless; and + # :r, material thermal resistance in m2•K/W. + # + # @param lc [OpenStudio::Model::LayeredConstruction] a layered construction + # + # @return [Hash] index: (Integer), type: (Symbol), r: (Float) + # @return [Hash] index: nil, type: nil, r: 0 if invalid input (see logs) + def insulatingLayer(lc = nil) + 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 + + ## + # Validates whether opaque surface can be considered as a curtain wall (or + # similar technology) spandrel, regardless of construction layers, by looking + # up AdditionalProperties or its identifier. + # + # @param s [OpenStudio::Model::Surface] an opaque surface + # + # @return [Bool] whether surface can be considered 'spandrel' + # @return [false] if invalid input (see logs) + def spandrel?(s = nil) + mth = "OSut::#{__callee__}" + cl = OpenStudio::Model::Surface + return invalid("surface", mth, 1, DBG, false) unless s.respond_to?(NS) + + id = s.nameString + m1 = "#{id}:spandrel" + m2 = "#{id}:spandrel:boolean" + + if s.additionalProperties.hasFeature("spandrel") + val = s.additionalProperties.getFeatureAsBoolean("spandrel") + return invalid(m1, mth, 1, ERR, false) if val.empty? + + val = val.get + return invalid(m2, mth, 1, ERR, false) unless [true, false].include?(val) + return val + end + + id.downcase.include?("spandrel") + end + + # ---- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- ---- # + # ---- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- ---- # + # This next set of utilities (~850 lines) help distinguish spaces that are + # directly vs indirectly CONDITIONED, vs SEMIHEATED. 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. + # standards like ASHRAE 90.1 and energy codes like the Canadian NECBs. + # # 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: # @@ -67,15 +1138,15 @@ # 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 + # - SEMIHEATED 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). + # space or a SEMIHEATED 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 @@ -92,215 +1163,263 @@ # 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. + # SEMIHEATED spaces are described in the NECB (yet not a defined term). The + # distinction is also based on desired/intended design space setpoint + # temperatures (here 15°C) - not system sizing criteria. No further treatment + # is implemented here to distinguish SEMIHEATED from CONDITIONED spaces; + # notwithstanding the AdditionalProperties tag (described further in this + # section), it is up to users to determine if a CONDITIONED space is + # indeed SEMIHEATED or not (e.g. based on MIN/MAX setpoints). # # 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. + # here are designed to process both classifications in the same way, namely + # by focusing on adjacent surfaces to CONDITIONED (or SEMIHEATED) 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. + # In light of the above, OSut methods here are designed without a priori + # knowledge of explicit system sizing choices or access to iterative + # autosizing processes. As discussed in greater detail below, methods here + # are developed to rely on zoning and/or "intended" temperature setpoints. + # In addition, OSut methods here cannot distinguish between UNCONDITIONED vs + # UNENCLOSED spaces from OpenStudio geometry alone. They are henceforth + # considered synonymous. # # For an OpenStudio model 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 OpenStudio spaces will be considered CONDITIONED, presuming setpoints of - # ~21°C (heating) and ~24°C (cooling). + # fully-formed ENCLOSED spaces WITHOUT thermal zoning information or setpoint + # temperatures (early design stage assessments of form, porosity or + # envelope), OpenStudio spaces are considered CONDITIONED by default. This + # default behaviour may be reset based on the (Space) AdditionalProperties + # "space_conditioning_category" key (4x possible values), which is relied + # upon by OpenStudio-Standards: # - # If ANY valid space/zone-specific temperature setpoints are found in the - # OpenStudio model, 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. + # github.com/NREL/openstudio-standards/blob/ + # d2b5e28928e712cb3f137ab5c1ad6d8889ca02b7/lib/openstudio-standards/ + # standards/Standards.Space.rb#L1604C5-L1605C1 + # + # OpenStudio-Standards recognizes 4x possible value strings: + # - "NonResConditioned" + # - "ResConditioned" + # - "Semiheated" + # - "Unconditioned" + # + # OSut maintains existing "space_conditioning_category" key/value pairs + # intact. Based on these, OSut methods may return related outputs: + # + # "space_conditioning_category" | OSut status | heating °C | cooling °C + # ------------------------------- ------------- ---------- ---------- + # - "NonResConditioned" CONDITIONED 21.0 24.0 + # - "ResConditioned" CONDITIONED 21.0 24.0 + # - "Semiheated" SEMIHEATED 15.0 NA + # - "Unconditioned" UNCONDITIONED NA NA + # + # OSut also looks up another (Space) AdditionalProperties 'key', + # "indirectlyconditioned" to flag plenum or occupied spaces indirectly + # conditioned with transfer air only. The only accepted 'value' for an + # "indirectlyconditioned" 'key' is the name (string) of another (linked) + # space, e.g.: + # + # "indirectlyconditioned" space | linked space, e.g. "core_space" + # ------------------------------- --------------------------------------- + # return air plenum occupied space below + # supply air plenum occupied space above + # dead air space (not a plenum) nearby occupied space + # + # OSut doesn't validate whether the "indirectlyconditioned" space is actually + # adjacent to its linked space. It nonetheless relies on the latter's + # conditioning category (e.g. CONDITIONED, SEMIHEATED) to determine + # anticipated ambient temperatures in the former. For instance, an + # "indirectlyconditioned"-tagged return air plenum linked to a SEMIHEATED + # space is considered as free-floating in terms of cooling, and unlikely to + # have ambient conditions below 15°C under heating (winter) design + # conditions. OSut will associate this plenum to a 15°C heating setpoint + # temperature. If the SEMIHEATED space instead has a heating setpoint + # temperature of 7°C, then OSut will associate a 7°C heating setpoint to this + # plenum. + # + # Even when a (more developed) OpenStudio model holds valid space/zone + # temperature setpoints, OSut gives priority to these AdditionalProperties. + # For instance, a CONDITIONED space can be considered INDIRECTLYCONDITIONED, + # even if its zone thermostat has a valid heating and/or cooling setpoint. + # This is in sync with OpenStudio-Standards' method + # "space_conditioning_category()". ## - # Return min & max values of a schedule (ruleset). + # Validates if model has zones with HVAC air loops. # - # @param sched [OpenStudio::Model::ScheduleRuleset] schedule + # @param model [OpenStudio::Model::Model] a model # + # @return [Bool] whether model has HVAC air loops + # @return [false] if invalid input (see logs) + def airLoopsHVAC?(model = nil) + mth = "OSut::#{__callee__}" + cl = OpenStudio::Model::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 + + ## + # Returns MIN/MAX values of a schedule (ruleset). + # + # @param sched [OpenStudio::Model::ScheduleRuleset] a schedule + # # @return [Hash] min: (Float), max: (Float) - # @return [Hash] min: nil, max: nil (if invalid input) + # @return [Hash] min: nil, max: nil if invalid inputs (see logs) def scheduleRulesetMinMax(sched = nil) # 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) - 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 } + values = sched.defaultDaySchedule.values.to_a - profiles.each do |profile| - id = profile.nameString + sched.scheduleRules.each { |rule| values += rule.daySchedule.values } - profile.values.each do |val| - ok = val.is_a?(Numeric) - log(WRN, "Skipping non-numeric value in '#{id}' (#{mth})") unless ok - next unless ok + res[:min] = values.min.is_a?(Numeric) ? values.min : nil + res[:max] = values.max.is_a?(Numeric) ? values.max : nil - 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). + # Returns MIN/MAX values of a schedule (constant). # - # @param sched [OpenStudio::Model::ScheduleConstant] schedule + # @param sched [OpenStudio::Model::ScheduleConstant] a schedule # # @return [Hash] min: (Float), max: (Float) - # @return [Hash] min: nil, max: nil (if invalid input) + # @return [Hash] min: nil, max: nil if invalid inputs (see logs) def scheduleConstantMinMax(sched = nil) # 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) - 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) - valid = sched.value.is_a?(Numeric) - mismatch("'#{id}' value", sched.value, Numeric, mth, ERR, res) unless valid + ok = sched.value.is_a?(Numeric) + mismatch("#{id} value", sched.value, Numeric, mth, ERR, res) unless ok res[:min] = sched.value res[:max] = sched.value res end ## - # Return min & max values of a schedule (compact). + # Returns 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) + # @return [Hash] min: nil, max: nil if invalid input (see logs) def scheduleCompactMinMax(sched = nil) # 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 } + mth = "OSut::#{__callee__}" + cl = OpenStudio::Model::ScheduleCompact + vals = [] + prev = "" + res = { min: nil, max: nil } + return invalid("sched", mth, 1, DBG, res) unless sched.respond_to?(NS) - 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") + if prev.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? + str = eg.getString(0) + prev = str.get.downcase unless str.empty? end - return empty("'#{id}' values", mth, ERR, res) if vals.empty? + return empty("#{id} values", mth, ERR, res) if vals.empty? - ok = vals.min.is_a?(Numeric) && vals.max.is_a?(Numeric) - log(ERR, "Non-numeric values in '#{id}' (#{mth})") unless ok - return res unless ok + res[:min] = vals.min.is_a?(Numeric) ? vals.min : nil + res[:max] = vals.min.is_a?(Numeric) ? vals.max : nil - res[:min] = vals.min - res[:max] = vals.max - res end ## - # Return min & max values for schedule (interval). + # Returns 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) + # @return [Hash] min: nil, max: nil if invalid input (see logs) def scheduleIntervalMinMax(sched = nil) mth = "OSut::#{__callee__}" cl = OpenStudio::Model::ScheduleInterval vals = [] res = { min: nil, max: nil } + return invalid("sched", mth, 1, DBG, res) unless sched.respond_to?(NS) - 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 - ok = vals.min.is_a?(Numeric) && vals.max.is_a?(Numeric) - log(ERR, "Non-numeric values in '#{id}' (#{mth})") unless ok - return res unless ok - res[:min] = vals.min - res[:max] = vals.max + res[:min] = vals.min.is_a?(Numeric) ? vals.min : nil + res[:max] = vals.max.is_a?(Numeric) ? vals.min : nil res end ## - # Return max zone heating temperature schedule setpoint [°C] and whether - # zone has active dual setpoint thermostat. + # Returns MAX zone heating temperature schedule setpoint [°C] and whether + # zone has an 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) + # @return [Hash] spt: nil, dual: false if invalid input (see logs) def maxHeatScheduledSetpoint(zone = nil) # Largely inspired from Parker & Marrec's "thermal_zone_heated?" procedure. - # The solution here is a tad more relaxed to encompass SEMI-HEATED zones as + # The solution here is a tad more relaxed to encompass SEMIHEATED 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) - 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| @@ -378,12 +1497,12 @@ end end return res if zone.thermostat.empty? - tstat = zone.thermostat.get - res[:spt] = nil + tstat = zone.thermostat.get + res[:spt] = nil unless tstat.to_ThermostatSetpointDualSetpoint.empty? && tstat.to_ZoneControlThermostatStagedDualSetpoint.empty? unless tstat.to_ThermostatSetpointDualSetpoint.empty? @@ -392,11 +1511,11 @@ tstat = tstat.to_ZoneControlThermostatStagedDualSetpoint.get end unless tstat.heatingSetpointTemperatureSchedule.empty? res[:dual] = true - sched = tstat.heatingSetpointTemperatureSchedule.get + sched = tstat.heatingSetpointTemperatureSchedule.get unless sched.to_ScheduleRuleset.empty? sched = sched.to_ScheduleRuleset.get max = scheduleRulesetMinMax(sched)[:max] @@ -451,49 +1570,47 @@ res end ## - # Validate if model has zones with valid heating temperature setpoints. + # Validates 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 + # @return [Bool] whether model holds valid heating temperature setpoints + # @return [false] if invalid input (see logs) def heatingTemperatureSetpoints?(model = nil) mth = "OSut::#{__callee__}" cl = OpenStudio::Model::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. + # Returns MIN zone cooling temperature schedule setpoint [°C] and whether + # zone has an 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) + # @return [Hash] spt: nil, dual: false if invalid input (see logs) def minCoolScheduledSetpoint(zone = nil) # 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) - 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| @@ -558,12 +1675,12 @@ end end return res if zone.thermostat.empty? - tstat = zone.thermostat.get - res[:spt] = nil + tstat = zone.thermostat.get + res[:spt] = nil unless tstat.to_ThermostatSetpointDualSetpoint.empty? && tstat.to_ZoneControlThermostatStagedDualSetpoint.empty? unless tstat.to_ThermostatSetpointDualSetpoint.empty? @@ -572,11 +1689,11 @@ tstat = tstat.to_ZoneControlThermostatStagedDualSetpoint.get end unless tstat.coolingSetpointTemperatureSchedule.empty? res[:dual] = true - sched = tstat.coolingSetpointTemperatureSchedule.get + sched = tstat.coolingSetpointTemperatureSchedule.get unless sched.to_ScheduleRuleset.empty? sched = sched.to_ScheduleRuleset.get min = scheduleRulesetMinMax(sched)[:min] @@ -631,141 +1748,341 @@ res end ## - # Validate if model has zones with valid cooling temperature setpoints. + # Validates 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 + # @return [Bool] whether model holds valid cooling temperature setpoints + # @return [false] if invalid input (see logs) def coolingTemperatureSetpoints?(model = nil) mth = "OSut::#{__callee__}" cl = OpenStudio::Model::Model + return mismatch("model", model, cl, mth, DBG, false) unless model.is_a?(cl) - 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. + # Validates whether space is a vestibule. # - # @param model [OpenStudio::Model::Model] a model + # @param space [OpenStudio::Model::Space] a space # - # @return [Bool] true if model has one or more HVAC air loops - # @return [Bool] false if invalid input - def airLoopsHVAC?(model = nil) + # @return [Bool] whether space is considered a vestibule + # @return [false] if invalid input (see logs) + def vestibule?(space = nil) + # INFO: OpenStudio-Standards' "thermal_zone_vestibule" criteria: + # - zones less than 200ft2; AND + # - having infiltration using Design Flow Rate + # + # github.com/NREL/openstudio-standards/blob/ + # 86bcd026a20001d903cc613bed6d63e94b14b142/lib/openstudio-standards/ + # standards/Standards.ThermalZone.rb#L1264 + # + # This (unused) OpenStudio-Standards method likely needs revision; it would + # return "false" if the thermal zone area were less than 200ft2. Not sure + # which edition of 90.1 relies on a 200ft2 threshold (2010?); 90.1 2016 + # doesn't. Yet even fixed, the method would nonetheless misidentify as + # "vestibule" a small space along an exterior wall, such as a semiheated + # storage space. + # + # The code below is intended as a simple short-term solution, basically + # relying on AdditionalProperties, or (if missing) a "vestibule" substring + # within a space's spaceType name (or the latter's standardsSpaceType). + # + # Alternatively, some future method could infer its status as a vestibule + # based on a few basic features (common to all vintages): + # - 1x+ outdoor-facing wall(s) holding 1x+ door(s) + # - adjacent to 1x+ 'occupied' conditioned space(s) + # - ideally, 1x+ door(s) between vestibule and 1x+ such adjacent space(s) + # + # An additional method parameter (i.e. std = :necb) could be added to + # ensure supplementary Standard-specific checks, e.g. maximum floor area, + # minimum distance between doors. + # + # Finally, an entirely separate method could be developed to first identify + # whether "building entrances" (a defined term in 90.1) actually require + # vestibules as per specific code requirements. Food for thought. mth = "OSut::#{__callee__}" - cl = OpenStudio::Model::Model + cl = OpenStudio::Model::Space + return mismatch("space", space, cl, mth, DBG, false) unless space.is_a?(cl) - return mismatch("model", model, cl, mth, DBG, false) unless model.is_a?(cl) + id = space.nameString + m1 = "#{id}:vestibule" + m2 = "#{id}:vestibule:boolean" - model.getThermalZones.each do |zone| - next if zone.canBePlenum - return true unless zone.airLoopHVACs.empty? - return true if zone.isPlenum + if space.additionalProperties.hasFeature("vestibule") + val = space.additionalProperties.getFeatureAsBoolean("vestibule") + return invalid(m1, mth, 1, ERR, false) if val.empty? + + val = val.get + return invalid(m2, mth, 1, ERR, false) unless [true, false].include?(val) + return val end + unless space.spaceType.empty? + type = space.spaceType.get + return false if type.nameString.downcase.include?("plenum") + return true if type.nameString.downcase.include?("vestibule") + + unless type.standardsSpaceType.empty? + type = type.standardsSpaceType.get.downcase + return false if type.include?("plenum") + return true if type.include?("vestibule") + end + end + false end ## - # Validate whether space should be processed as a plenum. + # Validates whether a space is an indirectly-conditioned 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 = nil, loops = nil, setpoints = nil) - # Largely inspired from NREL's "space_plenum?" procedure: + # @return [Bool] whether space is considered a plenum + # @return [false] if invalid input (see logs) + def plenum?(space = nil) + # Largely inspired from NREL's "space_plenum?": # - # github.com/NREL/openstudio-standards/blob/ - # 58964222d25783e9da4ae292e375fb0d5c902aa5/lib/openstudio-standards/ - # standards/Standards.Space.rb#L1384 + # github.com/NREL/openstudio-standards/blob/ + # 58964222d25783e9da4ae292e375fb0d5c902aa5/lib/openstudio-standards/ + # standards/Standards.Space.rb#L1384 # - # A space may be tagged as a plenum if: + # Ideally, "plenum?" should be in sync with OpenStudio SDK's "isPlenum" + # method, which solely looks for either HVAC air mixer objects: + # - AirLoopHVACReturnPlenum + # - AirLoopHVACSupplyPlenum # - # CASE A: its zone's "isPlenum" == true (SDK method) for a fully-developed - # OpenStudio model (complete with HVAC air loops); OR + # Of the OpenStudio-Standards Prototype models, only the LargeOffice + # holds AirLoopHVACReturnPlenum objects. OpenStudio-Standards' method + # "space_plenum?" indeed catches them by checking if the space is + # "partofTotalFloorArea" (which internally has an "isPlenum" check). So + # "isPlenum" closely follows ASHRAE 90.1 2016's definition of "plenum": # - # CASE B: (IN ABSENCE OF HVAC AIRLOOPS) if it's excluded from a building's - # total floor area yet linked to a zone holding an 'inactive' - # thermostat, i.e. can't extract valid setpoints; OR + # "plenum": a compartment or chamber ... + # - to which one or more ducts are connected + # - that forms a part of the air distribution system, and + # - that is NOT USED for occupancy or storage. # - # CASE C: (IN ABSENCE OF HVAC AIRLOOPS & VALID SETPOINTS) it has "plenum" - # (case insensitive) as a spacetype (or as a spacetype's - # 'standards spacetype'). + # Canadian NECB 2020 has the following (not as well) defined term: + # "plenum": a chamber forming part of an air duct system. + # ... we'll assume that a space shall also be considered + # UNOCCUPIED if it's "part of an air duct system". + # + # As intended, "isPlenum" would NOT identify as a "plenum" any vented + # UNCONDITIONED or UNENCLOSED attic or crawlspace - good. Yet "isPlenum" + # would also ignore dead air spaces integrating ducted return air. The + # SDK's "partofTotalFloorArea" would be more suitable in such cases, as + # long as modellers have, a priori, set this parameter to FALSE. + # + # OpenStudio-Standards' "space_plenum?" catches a MUCH WIDER range of + # spaces, which aren't caught by "isPlenum". This includes attics, + # crawlspaces, non-plenum air spaces above ceiling tiles, and any other + # UNOCCUPIED space in a model. The term "plenum" in this context is more + # of a catch-all shorthand - to be used with caution. For instance, + # "space_plenum?" shouldn't be used (in isolation) to determine whether an + # UNOCCUPIED space should have its envelope insulated ("plenum") or not + # ("attic"). + # + # In contrast to OpenStudio-Standards' "space_plenum?", this method + # strictly returns FALSE if a space is indeed "partofTotalFloorArea". It + # also returns FALSE if the space is a vestibule. Otherwise, it needs more + # information to determine if such an UNOCCUPIED space is indeed a + # plenum. Beyond these 2x criteria, a space is considered a plenum if: + # + # CASE A: it includes the substring "plenum" (case insensitive) in its + # spaceType's name, or in the latter's standardsSpaceType string; + # + # CASE B: "isPlenum" == TRUE in an OpenStudio model WITH HVAC airloops: OR + # + # CASE C: its zone holds an 'inactive' thermostat (i.e. can't extract valid + # setpoints) in an OpenStudio model with setpoint temperatures. + # + # If a modeller is instead simply interested in identifying UNOCCUPIED + # spaces that are INDIRECTLYCONDITIONED (not necessarily plenums), then the + # following combination is likely more reliable and less confusing: + # - SDK's partofTotalFloorArea == FALSE + # - OSut's unconditioned? == FALSE mth = "OSut::#{__callee__}" cl = OpenStudio::Model::Space + return mismatch("space", space, cl, mth, DBG, false) unless space.is_a?(cl) + return false if space.partofTotalFloorArea + return false if vestibule?(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) + m1 = "#{id}:plenum" + m1 = "#{id}:plenum boolean" - valid = loops == true || loops == false - return invalid("loops", mth, 2, DBG, false) unless valid + # CASE A: "plenum" spaceType. + unless space.spaceType.empty? + type = space.spaceType.get + return true if type.nameString.downcase.include?("plenum") - valid = setpoints == true || setpoints == false - return invalid("setpoints", mth, 3, DBG, false) unless valid + unless type.standardsSpaceType.empty? + type = type.standardsSpaceType.get.downcase + return true if type.include?("plenum") + end + end - unless space.thermalZone.empty? - zone = space.thermalZone.get - return zone.isPlenum if loops # A + # CASE B: "isPlenum" == TRUE if airloops. + return space.isPlenum if airLoopsHVAC?(space.model) - if setpoints - heat = maxHeatScheduledSetpoint(zone) - cool = minCoolScheduledSetpoint(zone) - return false if heat[:spt] || cool[:spt] # directly conditioned - return heat[:dual] || cool[:dual] unless space.partofTotalFloorArea # B - return false + # CASE C: zone holds an 'inactive' thermostat. + zone = space.thermalZone + heated = heatingTemperatureSetpoints?(space.model) + cooled = coolingTemperatureSetpoints?(space.model) + + if heated || cooled + return false if zone.empty? + + zone = zone.get + heat = maxHeatScheduledSetpoint(zone) + cool = minCoolScheduledSetpoint(zone) + return false if heat[:spt] || cool[:spt] # directly CONDITIONED + return heat[:dual] || cool[:dual] # FALSE if both are nilled + end + + false + end + + ## + # Retrieves a space's (implicit or explicit) heating/cooling setpoints. + # + # @param space [OpenStudio::Model::Space] a space + # + # @return [Hash] heating: (Float), cooling: (Float) + # @return [Hash] heating: nil, cooling: nil if invalid input (see logs) + def setpoints(space = nil) + mth = "OSut::#{__callee__}" + cl1 = OpenStudio::Model::Space + cl2 = String + res = {heating: nil, cooling: nil} + tg1 = "space_conditioning_category" + tg2 = "indirectlyconditioned" + cts = ["nonresconditioned", "resconditioned", "semiheated", "unconditioned"] + cnd = nil + return mismatch("space", space, cl1, mth, DBG, res) unless space.is_a?(cl1) + + # 1. Check for OpenStudio-Standards' space conditioning categories. + if space.additionalProperties.hasFeature(tg1) + cnd = space.additionalProperties.getFeatureAsString(tg1) + + if cnd.empty? + cnd = nil + else + cnd = cnd.get + + if cts.include?(cnd.downcase) + return res if cnd.downcase == "unconditioned" + else + invalid("#{tg1}:#{cnd}", mth, 0, ERR) + cnd = nil + end end end - unless space.spaceType.empty? - type = space.spaceType.get - return type.nameString.downcase == "plenum" # C + # 2. Check instead OSut's INDIRECTLYCONDITIONED (parent space) link. + if cnd.nil? + id = space.additionalProperties.getFeatureAsString(tg2) + + unless id.empty? + id = id.get + dad = space.model.getSpaceByName(id) + + if dad.empty? + log(ERR, "Unknown space #{id} (#{mth})") + else + # Now focus on 'parent' space linked to INDIRECTLYCONDITIONED space. + space = dad.get + cnd = tg2 + end + end end - unless type.standardsSpaceType.empty? - type = type.standardsSpaceType.get - return type.downcase == "plenum" # C + # 3. Fetch space setpoints (if model indeed holds valid setpoints). + heated = heatingTemperatureSetpoints?(space.model) + cooled = coolingTemperatureSetpoints?(space.model) + zone = space.thermalZone + + if heated || cooled + return res if zone.empty? # UNCONDITIONED + + zone = zone.get + res[:heating] = maxHeatScheduledSetpoint(zone)[:spt] + res[:cooling] = minCoolScheduledSetpoint(zone)[:spt] end - false + # 4. Reset if AdditionalProperties were found & valid. + unless cnd.nil? + if cnd.downcase == "unconditioned" + res[:heating] = nil + res[:cooling] = nil + elsif cnd.downcase == "semiheated" + res[:heating] = 15.0 if res[:heating].nil? + res[:cooling] = nil + elsif cnd.downcase.include?("conditioned") + # "nonresconditioned", "resconditioned" or "indirectlyconditioned" + res[:heating] = 21.0 if res[:heating].nil? # default + res[:cooling] = 24.0 if res[:cooling].nil? # default + end + end + + # 5. Reset if plenum? + if plenum?(space) + res[:heating] = 21.0 if res[:heating].nil? # default + res[:cooling] = 24.0 if res[:cooling].nil? # default + end + + res end ## - # Generate an HVAC availability schedule. + # Validates if a space is UNCONDITIONED. # + # @param space [OpenStudio::Model::Space] a space + # + # @return [Bool] whether space is considered UNCONDITIONED + # @return [false] if invalid input (see logs) + def unconditioned?(space = nil) + mth = "OSut::#{__callee__}" + cl = OpenStudio::Model::Space + return mismatch("space", space, cl, mth, DBG, false) unless space.is_a?(cl) + + ok = false + ok = setpoints(space)[:heating].nil? && setpoints(space)[:cooling].nil? + + ok + end + + ## + # Generates 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 [NilClass] if invalid input + # @return [nil] if invalid input (see logs) def availabilitySchedule(model = nil, avl = "") mth = "OSut::#{__callee__}" cl = OpenStudio::Model::Model limits = nil + return mismatch("model", model, cl, mth) unless model.is_a?(cl) + return invalid("availability", avl, 2, mth) unless avl.respond_to?(:to_s) - return mismatch("model", model, cl, mth) unless model.is_a?(cl) - return invalid("availability", avl, 2, mth) unless avl.respond_to?(:to_s) - # Either fetch availability ScheduleTypeLimits object, or create one. model.getScheduleTypeLimitss.each do |l| - break if limits - next if l.lowerLimitValue.empty? - next if l.upperLimitValue.empty? - next if l.numericType.empty? + 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" @@ -787,39 +2104,39 @@ 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? + 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.to_s.downcase - when "winter" # available from November 1 to April 30 (6 months) + case trim(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) + 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 + when "off" # never available val = 0 sch = on nom = "OFF Availability SchedRuleset" dft = "OFF Availability dftDaySched" tag = "" day = "" - else # always available + else # always available val = 1 sch = on nom = "ON Availability SchedRuleset" dft = "ON Availability dftDaySched" tag = "" @@ -833,18 +2150,18 @@ unless schedule.empty? schedule = schedule.get.to_ScheduleRuleset unless schedule.empty? schedule = schedule.get - default = schedule.defaultDaySchedule + 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) + ok = ok && rules.size < 2 if rules.size == 1 rule = rules.first ok = ok && rule.nameString == tag ok = ok && !rule.startDate.empty? @@ -865,650 +2182,798 @@ end end schedule = OpenStudio::Model::ScheduleRuleset.new(model) schedule.setName(nom) - ok = schedule.setScheduleTypeLimits(limits) - log(ERR, "'#{nom}': Can't set schedule type limits (#{mth})") unless ok - return nil unless ok - ok = schedule.defaultDaySchedule.addValue(time, val) - log(ERR, "'#{nom}': Can't set default day schedule (#{mth})") unless ok - return nil unless ok + 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) - ok = rule.setStartDate(may01) - log(ERR, "'#{tag}': Can't set start date (#{mth})") unless ok - return nil unless ok - ok = rule.setEndDate(oct31) - log(ERR, "'#{tag}': Can't set end date (#{mth})") unless ok - return nil unless ok + unless rule.setStartDate(may01) + log(ERR, "'#{tag}': Can't set start date (#{mth})") + return nil + end - ok = rule.setApplyAllDays(true) - log(ERR, "'#{tag}': Can't apply to all days (#{mth})") unless ok - return nil unless ok + 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 + # ---- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- ---- # + # ---- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- ---- # + # This final set of utilities targets OpenStudio geometry. Many of the + # following geometry methods rely on Boost as an OpenStudio dependency. + # As per Boost requirements, points (e.g. polygons) must first be 'aligned': + # - first rotated/tilted as to lay flat along XY plane (Z-axis ~= 0) + # - initial Z-axis values are represented as Y-axis values + # - points with the lowest X-axis values are 'aligned' along X-axis (0) + # - points with the lowest Z-axis values are 'aligned' along Y-axis (0) + # - for several Boost methods, points must be clockwise in sequence + # + # Check OSut's poly() method, which offers such Boost-related options. + ## - # Validate if default construction set holds a base construction. + # Returns OpenStudio site/space transformation & rotation angle [0,2PI) rads. # - # @param set [OpenStudio::Model::DefaultConstructionSet] a default set - # @param bse [OpensStudio::Model::ConstructionBase] a construction base - # @param gr [Bool] true if ground-facing surface - # @param ex [Bool] true if exterior-facing surface - # @param typ [String] a surface type + # @param group [OpenStudio::Model::PlanarSurfaceGroup] a site or space object # - # @return [Bool] true if default construction set holds construction - # @return [Bool] false if invalid input - def holdsConstruction?(set = nil, bse = nil, gr = false, ex = false, typ = "") + # @return [Hash] t: (OpenStudio::Transformation), r: (Float) + # @return [Hash] t: nil, r: nil if invalid input (see logs) + def transforms(group = nil) mth = "OSut::#{__callee__}" - cl1 = OpenStudio::Model::DefaultConstructionSet - cl2 = OpenStudio::Model::ConstructionBase + cl2 = OpenStudio::Model::PlanarSurfaceGroup + res = { t: nil, r: nil } + return invalid("group", mth, 2, DBG, res) unless group.respond_to?(NS) - return invalid("set", mth, 1, DBG, false) unless set.respond_to?(NS) + id = group.nameString + mdl = group.model + return mismatch(id, group, cl2, mth, DBG, res) unless group.is_a?(cl2) - id = set.nameString - return mismatch(id, set, cl1, mth, DBG, false) unless set.is_a?(cl1) - return invalid("base", mth, 2, DBG, false) unless bse.respond_to?(NS) + res[:t] = group.siteTransformation + res[:r] = group.directionofRelativeNorth + mdl.getBuilding.northAxis - id = bse.nameString - return mismatch(id, bse, cl2, mth, DBG, false) unless bse.is_a?(cl2) + res + end - valid = gr == true || gr == false - return invalid("ground", mth, 3, DBG, false) unless valid + ## + # Returns true if 2 OpenStudio 3D points are nearly equal + # + # @param p1 [OpenStudio::Point3d] 1st 3D point + # @param p2 [OpenStudio::Point3d] 2nd 3D point + # + # @return [Bool] whether equal points (within TOL) + # @return [false] if invalid input (see logs) + def same?(p1 = nil, p2 = nil) + mth = "OSut::#{__callee__}" + cl = OpenStudio::Point3d + return mismatch("point 1", p1, cl, mth, DBG, false) unless p1.is_a?(cl) + return mismatch("point 2", p2, cl, mth, DBG, false) unless p2.is_a?(cl) - valid = ex == true || ex == false - return invalid("exterior", mth, 4, DBG, false) unless valid + # OpenStudio.isAlmostEqual3dPt(p1, p2, TOL) # ... from v350 onwards. + (p1.x-p2.x).abs < TOL && (p1.y-p2.y).abs < TOL && (p1.z-p2.z).abs < TOL + end - valid = typ.respond_to?(:to_s) - return invalid("surface typ", mth, 4, DBG, false) unless valid + ## + # Returns true if a line segment is along the X-axis. + # + # @param p1 [OpenStudio::Point3d] 1st 3D point of a line segment + # @param p2 [OpenStudio::Point3d] 2nd 3D point of a line segment + # @param strict [Bool] whether segment shouldn't hold Y- or Z-axis components + # + # @return [Bool] whether along the X-axis + # @return [false] if invalid input (see logs) + def xx?(p1 = nil, p2 = nil, strict = true) + mth = "OSut::#{__callee__}" + cl = OpenStudio::Point3d + strict = true unless [true, false].include?(strict) + return mismatch("point 1", p1, cl, mth, DBG, false) unless p1.is_a?(cl) + return mismatch("point 2", p2, cl, mth, DBG, false) unless p2.is_a?(cl) + return false if (p1.y - p2.y).abs > TOL && strict + return false if (p1.z - p2.z).abs > TOL && strict - type = typ.to_s.downcase - valid = type == "floor" || type == "wall" || type == "roofceiling" - return invalid("surface type", mth, 5, DBG, false) unless valid + (p1.x - p2.x).abs > TOL + end - constructions = nil + ## + # Returns true if a line segment is along the Y-axis. + # + # @param p1 [OpenStudio::Point3d] 1st 3D point of a line segment + # @param p2 [OpenStudio::Point3d] 2nd 3D point of a line segment + # @param strict [Bool] whether segment shouldn't hold X- or Z-axis components + # + # @return [Bool] whether along the Y-axis + # @return [false] if invalid input (see logs) + def yy?(p1 = nil, p2 = nil, strict = true) + mth = "OSut::#{__callee__}" + cl = OpenStudio::Point3d + strict = true unless [true, false].include?(strict) + return mismatch("point 1", p1, cl, mth, DBG, false) unless p1.is_a?(cl) + return mismatch("point 2", p2, cl, mth, DBG, false) unless p2.is_a?(cl) + return false if (p1.x - p2.x).abs > TOL && strict + return false if (p1.z - p2.z).abs > TOL && strict - if gr - unless set.defaultGroundContactSurfaceConstructions.empty? - constructions = set.defaultGroundContactSurfaceConstructions.get - end - elsif ex - unless set.defaultExteriorSurfaceConstructions.empty? - constructions = set.defaultExteriorSurfaceConstructions.get - end - else - unless set.defaultInteriorSurfaceConstructions.empty? - constructions = set.defaultInteriorSurfaceConstructions.get - end - end + (p1.y - p2.y).abs > TOL + end - return false unless constructions + ## + # Returns true if a line segment is along the Z-axis. + # + # @param p1 [OpenStudio::Point3d] 1st 3D point of a line segment + # @param p2 [OpenStudio::Point3d] 2nd 3D point of a line segment + # @param strict [Bool] whether segment shouldn't hold X- or Y-axis components + # + # @return [Bool] whether along the Z-axis + # @return [false] if invalid input (see logs) + def zz?(p1 = nil, p2 = nil, strict = true) + mth = "OSut::#{__callee__}" + cl = OpenStudio::Point3d + strict = true unless [true, false].include?(strict) + return mismatch("point 1", p1, cl, mth, DBG, false) unless p1.is_a?(cl) + return mismatch("point 2", p2, cl, mth, DBG, false) unless p2.is_a?(cl) + return false if (p1.x - p2.x).abs > TOL && strict + return false if (p1.y - p2.y).abs > TOL && strict - case type - when "roofceiling" - unless constructions.roofCeilingConstruction.empty? - construction = constructions.roofCeilingConstruction.get - return true if construction == bse - end - when "floor" - unless constructions.floorConstruction.empty? - construction = constructions.floorConstruction.get - return true if construction == bse - end - else - unless constructions.wallConstruction.empty? - construction = constructions.wallConstruction.get - return true if construction == bse - end - end - - false + (p1.z - p2.z).abs > TOL end ## - # Return a surface's default construction set. + # Returns a scalar product of an OpenStudio Vector3d. # - # @param model [OpenStudio::Model::Model] a model - # @param s [OpenStudio::Model::Surface] a surface + # @param v [OpenStudio::Vector3d] a vector + # @param m [#to_f] a scalar # - # @return [OpenStudio::Model::DefaultConstructionSet] default set - # @return [NilClass] if invalid input - def defaultConstructionSet(model = nil, s = nil) + # @return [OpenStudio::Vector3d] scaled points (see logs if empty) + def scalar(v = OpenStudio::Vector3d.new, m = 0) mth = "OSut::#{__callee__}" - cl1 = OpenStudio::Model::Model - cl2 = OpenStudio::Model::Surface + cl = OpenStudio::Vector3d + ok = m.respond_to?(:to_f) + return mismatch("vector", v, cl, mth, DBG, v) unless v.is_a?(cl) + return mismatch("m", m, Numeric, mth, DBG, v) unless ok - return mismatch("model", model, cl1, mth) unless model.is_a?(cl1) - return invalid("s", mth, 2) unless s.respond_to?(NS) + m = m.to_f + OpenStudio::Vector3d.new(m * v.x, m * v.y, m * v.z) + end - id = s.nameString - return mismatch(id, s, cl2, mth) unless s.is_a?(cl2) + ## + # Returns OpenStudio 3D points as an OpenStudio point vector, validating + # points in the process (if Array). + # + # @param pts [Set<OpenStudio::Point3d>] OpenStudio 3D points + # + # @return [OpenStudio::Point3dVector] 3D vector (see logs if empty) + def to_p3Dv(pts = nil) + mth = "OSut::#{__callee__}" + cl1 = Array + cl2 = OpenStudio::Point3dVector + cl3 = OpenStudio::Model::PlanarSurface + cl4 = OpenStudio::Point3d + v = OpenStudio::Point3dVector.new + return pts if pts.is_a?(cl2) + return pts.vertices if pts.is_a?(cl3) + return mismatch("points", pts, cl1, mth, DBG, v) unless pts.is_a?(cl1) - ok = s.isConstructionDefaulted - log(ERR, "'#{id}' construction not defaulted (#{mth})") unless ok - return nil unless ok - return empty("'#{id}' construction", mth, ERR) if s.construction.empty? + pts.each do |pt| + return mismatch("point", pt, cl4, mth, DBG, v) unless pt.is_a?(cl4) + end - base = s.construction.get - return empty("'#{id}' space", mth, ERR) if s.space.empty? + pts.each { |pt| v << OpenStudio::Point3d.new(pt.x, pt.y, pt.z) } - space = s.space.get - type = s.surfaceType - ground = false - exterior = false + v + end - if s.isGroundSurface - ground = true - elsif s.outsideBoundaryCondition.downcase == "outdoors" - exterior = true - end + ## + # Returns true if an OpenStudio 3D point is part of a set of 3D points. + # + # @param pts [Set<OpenStudio::Point3dVector>] 3d points + # @param p1 [OpenStudio::Point3d] a 3D point + # + # @return [Bool] whether part of a set of 3D points + # @return [false] if invalid input (see logs) + def holds?(pts = nil, p1 = nil) + mth = "OSut::#{__callee__}" + pts = to_p3Dv(pts) + cl = OpenStudio::Point3d + return mismatch("point", p1, cl, mth, DBG, false) unless p1.is_a?(cl) - unless space.defaultConstructionSet.empty? - set = space.defaultConstructionSet.get - return set if holdsConstruction?(set, base, ground, exterior, type) - end + pts.each { |pt| return true if same?(p1, pt) } - unless space.spaceType.empty? - spacetype = space.spaceType.get + false + end - unless spacetype.defaultConstructionSet.empty? - set = spacetype.defaultConstructionSet.get - return set if holdsConstruction?(set, base, ground, exterior, type) - end - end + ## + # Flattens OpenStudio 3D points vs X, Y or Z axes. + # + # @param pts [Set<OpenStudio::Point3d>] 3D points + # @param axs [Symbol] :x, :y or :z axis + # @param val [#to_f] axis value + # + # @return [OpenStudio::Point3dVector] flattened points (see logs if empty) + def flatten(pts = nil, axs = :z, val = 0) + mth = "OSut::#{__callee__}" + pts = to_p3Dv(pts) + v = OpenStudio::Point3dVector.new + ok1 = val.respond_to?(:to_f) + ok2 = [:x, :y, :z].include?(axs) + return mismatch("val", val, Numeric, mth, DBG, v) unless ok1 + return invalid("axis (XYZ?)", mth, 2, DBG, v) unless ok2 - unless space.buildingStory.empty? - story = space.buildingStory.get + val = val.to_f - unless story.defaultConstructionSet.empty? - set = story.defaultConstructionSet.get - return set if holdsConstruction?(set, base, ground, exterior, type) - end + case axs + when :x + pts.each { |pt| v << OpenStudio::Point3d.new(val, pt.y, pt.z) } + when :y + pts.each { |pt| v << OpenStudio::Point3d.new(pt.x, val, pt.z) } + else + pts.each { |pt| v << OpenStudio::Point3d.new(pt.x, pt.y, val) } end - building = model.getBuilding - - unless building.defaultConstructionSet.empty? - set = building.defaultConstructionSet.get - return set if holdsConstruction?(set, base, ground, exterior, type) - end - - nil + v end ## - # Validate if every material in a layered construction is standard & opaque. + # Returns true if OpenStudio 3D points share X, Y or Z coordinates. # - # @param lc [OpenStudio::LayeredConstruction] a layered construction + # @param pts [Set<OpenStudio::Point3d>] OpenStudio 3D points + # @param axs [Symbol] if potentially along :x, :y or :z axis + # @param val [Numeric] axis value # - # @return [Bool] true if all layers are valid - # @return [Bool] false if invalid input - def standardOpaqueLayers?(lc = nil) + # @return [Bool] if points share X, Y or Z coordinates + # @return [false] if invalid input (see logs) + def xyz?(pts = nil, axs = :z, val = 0) mth = "OSut::#{__callee__}" - cl = OpenStudio::Model::LayeredConstruction + pts = to_p3Dv(pts) + ok1 = val.respond_to?(:to_f) + ok2 = [:x, :y, :z].include?(axs) + return false if pts.empty? + return mismatch("val", val, Numeric, mth, DBG, false) unless ok1 + return invalid("axis (XYZ?)", mth, 2, DBG, false) unless ok2 - 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) + val = val.to_f - lc.layers.each { |m| return false if m.to_StandardOpaqueMaterial.empty? } + case axs + when :x + pts.each { |pt| return false if (pt.x - val).abs > TOL } + when :y + pts.each { |pt| return false if (pt.y - val).abs > TOL } + else + pts.each { |pt| return false if (pt.z - val).abs > TOL } + end true end ## - # Total (standard opaque) layered construction thickness (in m). + # Returns next sequential point in an OpenStudio 3D point vector. # - # @param lc [OpenStudio::LayeredConstruction] a layered construction + # @param pts [OpenStudio::Point3dVector] 3D points + # @param pt [OpenStudio::Point3d] a given 3D point # - # @return [Float] total layered construction thickness - # @return [Float] 0 if invalid input - def thickness(lc = nil) + # @return [OpenStudio::Point3d] the next sequential point + # @return [nil] if invalid input (see logs) + def next(pts = nil, pt = nil) mth = "OSut::#{__callee__}" - cl = OpenStudio::Model::LayeredConstruction + pts = to_p3Dv(pts) + cl = OpenStudio::Point3d + return mismatch("point", pt, cl, mth) unless pt.is_a?(cl) + return invalid("points (2+)", mth, 1, WRN) if pts.size < 2 - return invalid("lc", mth, 1, DBG, 0.0) unless lc.respond_to?(NS) + pair = pts.each_cons(2).find { |p1, _| same?(p1, pt) } - id = lc.nameString - return mismatch(id, lc, cl, mth, DBG, 0.0) unless lc.is_a?(cl) - - ok = standardOpaqueLayers?(lc) - log(ERR, "'#{id}' holds non-StandardOpaqueMaterial(s) (#{mth})") unless ok - return 0.0 unless ok - - thickness = 0.0 - lc.layers.each { |m| thickness += m.thickness } - - thickness + pair.nil? ? pts.first : pair.last end ## - # Return total air film resistance for fenestration. + # Returns unique OpenStudio 3D points from an OpenStudio 3D point vector. # - # @param usi [Float] a fenestrated construction's U-factor (W/m2•K) + # @param pts [Set<OpenStudio::Point3d] 3D points + # @param n [#to_i] requested number of unique points (0 returns all) # - # @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. + # @return [OpenStudio::Point3dVector] unique points (see logs if empty) + def getUniques(pts = nil, n = 0) mth = "OSut::#{__callee__}" - cl = Numeric + pts = to_p3Dv(pts) + ok = n.respond_to?(:to_i) + v = OpenStudio::Point3dVector.new + return v if pts.empty? + return mismatch("n unique points", n, Integer, mth, DBG, v) unless ok - 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 - return negative("usi", mth, WRN, 0.1216) if usi < 0 - return zero("usi", mth, WRN, 0.1216) if usi.abs < TOL + pts.each { |pt| v << pt unless holds?(v, pt) } - rsi = 1 / (0.025342 * usi + 29.163853) # exterior film, next interior film + n = n.to_i + n = 0 unless n.abs < v.size + v = v[0..n] if n > 0 + v = v[n..-1] if n < 0 - return rsi + 1 / (0.359073 * Math.log(usi) + 6.949915) if usi < 5.85 - return rsi + 1 / (1.788041 * usi - 2.886625) + v end ## - # Return a construction's 'standard calc' thermal resistance (with air films). + # Returns sequential non-collinear points in an OpenStudio 3D point vector. # - # @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) + # @param pts [Set<OpenStudio::Point3d] 3D points + # @param n [#to_i] requested number of non-collinears (0 returns all) # - # @return [Float] calculated RSi at standard conditions (0 if error) - def rsi(lc = nil, film = 0.0, 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 + # @return [OpenStudio::Point3dVector] non-collinears (see logs if empty) + def getNonCollinears(pts = nil, n = 0) mth = "OSut::#{__callee__}" - cl1 = OpenStudio::Model::LayeredConstruction - cl2 = Numeric + pts = getUniques(pts) + ok = n.respond_to?(:to_i) + v = OpenStudio::Point3dVector.new + a = [] + return pts if pts.size < 2 + return mismatch("n non-collinears", n, Integer, mth, DBG, v) unless ok - return invalid("lc", mth, 1, DBG, 0.0) unless lc.respond_to?(NS) + # Evaluate cross product of vectors of 3x sequential points. + pts.each_with_index do |p2, i2| + i1 = i2 - 1 + i3 = i2 + 1 + i3 = 0 if i3 == pts.size + p1 = pts[i1] + p3 = pts[i3] + v13 = p3 - p1 + v12 = p2 - p1 + next if v12.cross(v13).length < TOL - id = lc.nameString + a << p2 + end - return mismatch(id, lc, cl1, mth, DBG, 0.0) unless lc.is_a?(cl1) - return mismatch("film", film, cl2, mth, DBG, 0.0) unless film.is_a?(cl2) - return mismatch("temp K", t, cl2, mth, DBG, 0.0) unless t.is_a?(cl2) - - t += 273.0 # °C to K - return negative("temp K", mth, DBG, 0.0) if t < 0 - return negative("film", mth, DBG, 0.0) if film < 0 - - rsi = film - - lc.layers.each do |m| - # Fenestration materials first (ignoring shades, screens, etc.) - empty = m.to_SimpleGlazing.empty? - return 1 / m.to_SimpleGlazing.get.uFactor unless empty - - empty = m.to_StandardGlazing.empty? - rsi += m.to_StandardGlazing.get.thermalResistance unless empty - empty = m.to_RefractionExtinctionGlazing.empty? - rsi += m.to_RefractionExtinctionGlazing.get.thermalResistance unless empty - empty = m.to_Gas.empty? - rsi += m.to_Gas.get.getThermalResistance(t) unless empty - empty = m.to_GasMixture.empty? - rsi += m.to_GasMixture.get.getThermalResistance(t) unless empty - - # Opaque materials next. - empty = m.to_StandardOpaqueMaterial.empty? - rsi += m.to_StandardOpaqueMaterial.get.thermalResistance unless empty - empty = m.to_MasslessOpaqueMaterial.empty? - rsi += m.to_MasslessOpaqueMaterial.get.thermalResistance unless empty - empty = m.to_RoofVegetation.empty? - rsi += m.to_RoofVegetation.get.thermalResistance unless empty - empty = m.to_AirGap.empty? - rsi += m.to_AirGap.get.thermalResistance unless empty + if holds?(a, pts[0]) + a = a.rotate(-1) unless same?(a[0], pts[0]) end - rsi + n = n.to_i + n = 0 unless n.abs < pts.size + a = a[0..n] if n > 0 + a = a[n..-1] if n < 0 + + to_p3Dv(a) 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). + # Returns paired sequential points as (non-zero length) line segments. If the + # set strictly holds 2x unique points, a single segment is returned. + # Otherwise, the returned number of segments equals the number of unique + # points. If non-collinearity is requested, then the number of returned + # segments equals the number of non-colliear points. # - # @param lc [OpenStudio::Model::LayeredConstruction] a layered construction + # @param pts [Set<OpenStudio::Point3d>] 3D points + # @param co [Bool] whether to keep collinear points # - # @return [Hash] index: (Integer), type: (:standard or :massless), r: (Float) - # @return [Hash] index: nil, type: nil, r: 0 (if invalid input) - def insulatingLayer(lc = nil) + # @return [OpenStudio::Point3dVectorVector] line segments (see logs if empty) + def getSegments(pts = nil, co = false) mth = "OSut::#{__callee__}" - cl = OpenStudio::Model::LayeredConstruction - res = { index: nil, type: nil, r: 0.0 } - i = 0 # iterator + vv = OpenStudio::Point3dVectorVector.new + co = false unless [true, false].include?(co) + pts = getNonCollinears(pts) unless co + pts = getUniques(pts) if co + return vv if pts.size < 2 - return invalid("lc", mth, 1, DBG, res) unless lc.respond_to?(NS) + pts.each_with_index do |p1, i1| + i2 = i1 + 1 + i2 = 0 if i2 == pts.size + p2 = pts[i2] - 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 + line = OpenStudio::Point3dVector.new + line << p1 + line << p2 + vv << line + break if pts.size == 2 end - res + vv end ## - # Return OpenStudio site/space transformation & rotation angle [0,2PI) rads. + # Returns points as (non-zero length) 'triads', i.e. 3x sequential points. + # If the set holds less than 3x unique points, an empty triad is + # returned. Otherwise, the returned number of triads equals the number of + # unique points. If non-collinearity is requested, then the number of + # returned triads equals the number of non-collinear points. # - # @param model [OpenStudio::Model::Model] a model - # @param group [OpenStudio::Model::PlanarSurfaceGroup] a group + # @param pts [OpenStudio::Point3dVector] 3D points + # @param co [Bool] whether to keep collinear points # - # @return [Hash] t: (OpenStudio::Transformation), r: Float - # @return [Hash] t: nil, r: nil (if invalid input) - def transforms(model = nil, group = nil) + # @return [OpenStudio::Point3dVectorVector] triads (see logs if empty) + def getTriads(pts = nil, co = false) mth = "OSut::#{__callee__}" - cl1 = OpenStudio::Model::Model - cl2 = OpenStudio::Model::PlanarSurfaceGroup - res = { t: nil, r: nil } + vv = OpenStudio::Point3dVectorVector.new + co = false unless [true, false].include?(co) + pts = getNonCollinears(pts) unless co + pts = getUniques(pts) if co + return vv if pts.size < 2 - 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) + pts.each_with_index do |p1, i1| + i2 = i1 + 1 + i2 = 0 if i2 == pts.size + i3 = i2 + 1 + i3 = 0 if i3 == pts.size + p2 = pts[i2] + p3 = pts[i3] - id = group.nameString - return mismatch(id, group, cl2, mth, DBG, res) unless group.is_a?(cl2) + tri = OpenStudio::Point3dVector.new + tri << p1 + tri << p2 + tri << p3 + vv << tri + end - res[:t] = group.siteTransformation - res[:r] = group.directionofRelativeNorth + model.getBuilding.northAxis - - res + vv end ## - # Return a scalar product of an OpenStudio Vector3d. + # Determines if pre-'aligned' OpenStudio 3D points are listed clockwise. # - # @param v [OpenStudio::Vector3d] a vector - # @param m [Float] a scalar + # @param pts [OpenStudio::Point3dVector] 3D points # - # @return [OpenStudio::Vector3d] modified vector - # @return [OpenStudio::Vector3d] provided (or empty) vector if invalid input - def scalar(v = OpenStudio::Vector3d.new(0,0,0), m = 0) + # @return [Bool] whether sequence is clockwise + # @return [false] if invalid input (see logs) + def clockwise?(pts = nil) mth = "OSut::#{__callee__}" - cl1 = OpenStudio::Vector3d - cl2 = Numeric + pts = to_p3Dv(pts) + n = false + return invalid("points (3+)", mth, 1, DBG, n) if pts.size < 3 + return invalid("points (aligned)", mth, 1, DBG, n) unless xyz?(pts, :z, 0) - return mismatch("vector", v, cl1, mth, DBG, v) unless v.is_a?(cl1) - return mismatch("x", v.x, cl2, mth, DBG, v) unless v.x.respond_to?(:to_f) - return mismatch("y", v.y, cl2, mth, DBG, v) unless v.y.respond_to?(:to_f) - return mismatch("z", v.z, cl2, mth, DBG, v) unless v.z.respond_to?(:to_f) - return mismatch("m", m, cl2, mth, DBG, v) unless m.respond_to?(:to_f) - - OpenStudio::Vector3d.new(m * v.x, m * v.y, m * v.z) + OpenStudio.pointInPolygon(pts.first, pts, TOL) end ## - # Flatten OpenStudio 3D points vs Z-axis (Z=0). + # Returns 'aligned' OpenStudio 3D points conforming to Openstudio's + # counterclockwise UpperLeftCorner (ULC) convention. # - # @param pts [Array] an OpenStudio Point3D array/vector + # @param pts [Set<OpenStudio::Point3d>] aligned 3D points # - # @return [Array] flattened OpenStudio 3D points - def flatZ(pts = nil) + # @return [OpenStudio::Point3dVector] ULC points (see logs if empty) + def ulc(pts = nil) mth = "OSut::#{__callee__}" - cl1 = OpenStudio::Point3dVector - cl2 = OpenStudio::Point3d + pts = to_p3Dv(pts) v = OpenStudio::Point3dVector.new + p0 = OpenStudio::Point3d.new(0,0,0) + i0 = nil - valid = pts.is_a?(cl1) || pts.is_a?(Array) - return mismatch("points", pts, cl1, mth, DBG, v) unless valid + return invalid("points (3+)", mth, 1, DBG, v) if pts.size < 3 + return invalid("points (aligned)", mth, 1, DBG, v) unless xyz?(pts, :z, 0) - 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) } + # Ensure counterclockwise sequence. + pts = pts.to_a + pts = pts.reverse if clockwise?(pts) - v + # Fetch index of candidate (0,0,0) point (i == 1, in most cases). Resort + # to last X == 0 point. Leave as is if failed attempts. + i0 = pts.index { |pt| same?(pt, p0) } + i0 = pts.rindex { |pt| pt.x.abs < TOL } if i0.nil? + + unless i0.nil? + i = pts.size - 1 + i = i0 - 1 unless i0 == 0 + pts = pts.rotate(i) + end + + to_p3Dv(pts) end ## - # Validate whether 1st OpenStudio convex polygon fits in 2nd convex polygon. + # Returns an OpenStudio 3D point vector as basis for a valid OpenStudio 3D + # polygon. In addition to basic OpenStudio polygon tests (e.g. all points + # sharing the same 3D plane, non-self-intersecting), the method can + # optionally check for convexity, or ensure uniqueness and/or collinearity. + # Returned vector can also be 'aligned', as well as in UpperLeftCorner (ULC) + # counterclockwise sequence, or in clockwise sequence. # - # @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) + # @param pts [Set<OpenStudio::Point3d>] 3D points + # @param vx [Bool] whether to check for convexity + # @param uq [Bool] whether to ensure uniqueness + # @param co [Bool] whether to ensure non-collinearity + # @param tt [Bool, OpenStudio::Transformation] whether to 'align' + # @param sq [:no, :ulc, :cw] unaltered, ULC or clockwise sequence # - # @return [Bool] true if 1st polygon fits entirely within the 2nd polygon - # @return [Bool] false if invalid input - def fits?(p1 = nil, p2 = nil, id1 = "", id2 = "") + # @return [OpenStudio::Point3dVector] 3D points (see logs if empty) + def poly(pts = nil, vx = false, uq = false, co = true, tt = false, sq = :no) mth = "OSut::#{__callee__}" - cl1 = OpenStudio::Point3dVector - cl2 = OpenStudio::Point3d - a = false + pts = to_p3Dv(pts) + cl = OpenStudio::Transformation + v = OpenStudio::Point3dVector.new + vx = false unless [true, false].include?(vx) + uq = false unless [true, false].include?(uq) + co = true unless [true, false].include?(co) - return invalid("id1", mth, 3, DBG, a) unless id1.respond_to?(:to_s) - return invalid("id2", mth, 4, DBG, a) unless id2.respond_to?(:to_s) + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + # Exit if mismatched/invalid arguments. + ok1 = tt == true || tt == false || tt.is_a?(cl) + ok2 = sq == :no || sq == :ulc || sq == :cw + return invalid("transformation", mth, 5, DBG, v) unless ok1 + return invalid("sequence", mth, 6, DBG, v) unless ok2 - i1 = id1.to_s - i2 = id2.to_s - i1 = "poly1" if i1.empty? - i2 = "poly2" if i2.empty? + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + # Basic tests: + p3 = getNonCollinears(pts, 3) + return empty("polygon", mth, ERR, v) if p3.size < 3 - valid1 = p1.is_a?(cl1) || p1.is_a?(Array) - valid2 = p2.is_a?(cl1) || p2.is_a?(Array) + pln = OpenStudio::Plane.new(p3) - return mismatch(i1, p1, cl1, mth, DBG, a) unless valid1 - return mismatch(i2, p2, cl1, mth, DBG, a) unless valid2 - return empty(i1, mth, ERR, a) if p1.empty? - return empty(i2, mth, ERR, a) if p2.empty? + pts.each do |pt| + return empty("plane", mth, ERR, v) unless pln.pointOnPlane(pt) + end - 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) } + t = tt + t = OpenStudio::Transformation.alignFace(pts) unless tt.is_a?(cl) + a = (t.inverse * pts).reverse - # XY-plane transformation matrix ... needs to be clockwise for boost. - ft = OpenStudio::Transformation.alignFace(p1) - ft_p1 = flatZ( (ft.inverse * p1) ) - return false if ft_p1.empty? + if tt.is_a?(cl) + # Using a transformation that is most likely not specific to pts. The + # most probable reason to retain this option is when testing for polygon + # intersections, unions, etc., operations that typically require that + # points remain nonetheless 'aligned'. If re-activated, this logs a + # warning if aligned points aren't @Z =0, before 'flattening'. + # + # invalid("points (non-aligned)", mth, 1, WRN) unless xyz?(a, :z, 0) + a = flatten(a).to_a unless xyz?(a, :z, 0) + end - cw = OpenStudio.pointInPolygon(ft_p1.first, ft_p1, TOL) - ft_p1 = flatZ( (ft.inverse * p1).reverse ) unless cw - ft_p2 = flatZ( (ft.inverse * p2).reverse ) unless cw - ft_p2 = flatZ( (ft.inverse * p2) ) if cw - return false if ft_p2.empty? + # The following 2x lines are commented out. This is a very commnon and very + # useful test, yet tested cases are first caught by the 'pointOnPlane' + # test above. Keeping it for possible further testing. + # bad = OpenStudio.selfIntersects(a, TOL) + # return invalid("points (intersecting)", mth, 1, ERR, v) if bad - area1 = OpenStudio.getArea(ft_p1) - area2 = OpenStudio.getArea(ft_p2) - return empty("#{i1} area", mth, ERR, a) if area1.empty? - return empty("#{i2} area", mth, ERR, a) if area2.empty? + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + # Ensure uniqueness and/or non-collinearity. Preserve original sequence. + p0 = a.first + a = OpenStudio.simplify(a, false, TOL) if uq + a = OpenStudio.simplify(a, true, TOL) unless co + i0 = a.index { |pt| same?(pt, p0) } + a = a.rotate(i0) unless i0.nil? - area1 = area1.get - area2 = area2.get - union = OpenStudio.join(ft_p1, ft_p2, TOL2) - return false if union.empty? + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + # Check for convexity (optional). + if vx + a1 = OpenStudio.simplify(a, true, TOL).reverse + dX = a1.max_by(&:x).x.abs + dY = a1.max_by(&:y).y.abs + d = [dX, dY].max + return false if d < TOL - union = union.get - area = OpenStudio.getArea(union) - return empty("#{i1}:#{i2} union area", mth, ERR, a) if area.empty? + u = OpenStudio::Vector3d.new(0, 0, d) - area = area.get + a1.each_with_index do |p1, i1| + i2 = i1 + 1 + i2 = 0 if i2 == a1.size + p2 = a1[i2] + pi = p1 + u + vi = OpenStudio::Point3dVector.new + vi << pi + vi << p1 + vi << p2 + plane = OpenStudio::Plane.new(vi) + normal = plane.outwardNormal - return false if area < TOL - return true if (area - area2).abs < TOL - return false if (area - area2).abs > TOL + a1.each do |p3| + next if same?(p1, p3) + next if same?(p2, p3) + next if plane.pointOnPlane(p3) + next if normal.dot(p3 - p1) < 0 - true + return invalid("points (non-convex)", mth, 1, ERR, v) + end + end + end + + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + # Alter sequence (optional). + unless tt + case sq + when :ulc + a = to_p3Dv(t * ulc(a.reverse)) + when :cw + a = to_p3Dv(t * a) + a = OpenStudio.reverse(a) unless clockwise?(a) + else + a = to_p3Dv(t * a.reverse) + end + else + case sq + when :ulc + a = ulc(a.reverse) + when :cw + a = to_p3Dv(a) + a = OpenStudio.reverse(a) unless clockwise?(a) + else + a = to_p3Dv(a.reverse) + end + end + + a end ## - # Validate whether an OpenStudio polygon overlaps another. + # Returns 'width' of a set of OpenStudio 3D points (perpendicular view). # - # @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) + # @param pts [Set<OpenStudio::Point3d>] 3D points # - # @return Returns true if polygons overlaps (or either fits into the other) - # @return [Bool] false if invalid input - def overlaps?(p1 = nil, p2 = nil, id1 = "", id2 = "") + # @return [Float] left-to-right width + # @return [0.0] if invalid inputs (see logs) + def width(pts = nil) mth = "OSut::#{__callee__}" - cl1 = OpenStudio::Point3dVector - cl2 = OpenStudio::Point3d - a = false - return invalid("id1", mth, 3, DBG, a) unless id1.respond_to?(:to_s) - return invalid("id2", mth, 4, DBG, a) unless id2.respond_to?(:to_s) + poly(pts, false, true, false, true).max_by(&:x).x + end - i1 = id1.to_s - i2 = id2.to_s - i1 = "poly1" if i1.empty? - i2 = "poly2" if i2.empty? + ## + # Returns 'height' of a set of OpenStudio 3D points (perpendicular view). + # + # @param pts [Set<OpenStudio::Point3d>] 3D points + # + # @return [Float] top-to-bottom height + # @return [0.0] if invalid inputs (see logs) + def height(pts = nil) + mth = "OSut::#{__callee__}" - valid1 = p1.is_a?(cl1) || p1.is_a?(Array) - valid2 = p2.is_a?(cl1) || p2.is_a?(Array) + poly(pts, false, true, false, true).max_by(&:y).y + end - return mismatch(i1, p1, cl1, mth, DBG, a) unless valid1 - return mismatch(i2, p2, cl1, mth, DBG, a) unless valid2 - return empty(i1, mth, ERR, a) if p1.empty? - return empty(i2, mth, ERR, a) if p2.empty? + ## + # Determines whether a 1st OpenStudio polygon fits in a 2nd polygon. + # + # @param p1 [Set<OpenStudio::Point3d>] 1st set of 3D points + # @param p2 [Set<OpenStudio::Point3d>] 2nd set of 3D points + # @param flat [Bool] whether points are to be pre-flattened (Z=0) + # + # @return [Bool] whether 1st polygon fits within the 2nd polygon + # @return [false] if invalid input (see logs) + def fits?(p1 = nil, p2 = nil, flat = true) + mth = "OSut::#{__callee__}" + p1 = poly(p1, false, true, false) + p2 = poly(p2, false, true, false) + flat = true unless [true, false].include?(flat) + return false if p1.empty? + return false 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) } + # Aligned, clockwise points using transformation from 2nd polygon. + t = OpenStudio::Transformation.alignFace(p2) + p1 = poly(p1, false, false, true, t, :cw) + p2 = poly(p2, false, false, true, t, :cw) + p1 = flatten(p1) if flat + p2 = flatten(p2) if flat + return false if p1.empty? + return false if p2.empty? - # XY-plane transformation matrix ... needs to be clockwise for boost. - ft = OpenStudio::Transformation.alignFace(p1) - ft_p1 = flatZ( (ft.inverse * p1) ) - ft_p2 = flatZ( (ft.inverse * p2) ) - return false if ft_p1.empty? - return false if ft_p2.empty? + area1 = OpenStudio.getArea(p1) + area2 = OpenStudio.getArea(p2) + return empty("points 1 area", mth, ERR, false) if area1.empty? + return empty("points 2 area", mth, ERR, false) if area2.empty? - cw = OpenStudio.pointInPolygon(ft_p1.first, ft_p1, TOL) - ft_p1 = flatZ( (ft.inverse * p1).reverse ) unless cw - ft_p2 = flatZ( (ft.inverse * p2).reverse ) unless cw - return false if ft_p1.empty? - return false if ft_p2.empty? - - area1 = OpenStudio.getArea(ft_p1) - area2 = OpenStudio.getArea(ft_p2) - return empty("#{i1} area", mth, ERR, a) if area1.empty? - return empty("#{i2} area", mth, ERR, a) if area2.empty? - area1 = area1.get area2 = area2.get - union = OpenStudio.join(ft_p1, ft_p2, TOL2) - return false if union.empty? + union = OpenStudio.join(p1, p2, TOL2) + return false if union.empty? union = union.get area = OpenStudio.getArea(union) - return empty("#{i1}:#{i2} union area", mth, ERR, a) if area.empty? + return false if area.empty? area = area.get - return false if area < TOL - delta = (area - area1 - area2).abs - return false if delta < TOL + if area > TOL + return true if (area - area2).abs < TOL + end - true + false end ## - # Generate offset vertices (by width) for a 3- or 4-sided, convex polygon. + # Determines whether OpenStudio polygons overlap. # - # @param p1 [OpenStudio::Point3dVector] OpenStudio Point3D vector/array - # @param w [Float] offset width (min: 0.0254m) - # @param v [Integer] OpenStudio SDK version, eg '321' for 'v3.2.1' (optional) + # @param p1 [Set<OpenStudio::Point3d>] 1st set of 3D points + # @param p2 [Set<OpenStudio::Point3d>] 2nd set of 3D points + # @param flat [Bool] whether points are to be pre-flattened (Z=0) # - # @return [OpenStudio::Point3dVector] offset points if successful - # @return [OpenStudio::Point3dVector] original points if invalid input - def offset(p1 = [], w = 0, v = 0) - mth = "OSut::#{__callee__}" - cl = OpenStudio::Point3d - vrsn = OpenStudio.openStudioVersion.split(".").map(&:to_i).join.to_i + # @return [Bool] whether polygons overlap (or fit) + # @return [false] if invalid input (see logs) + def overlaps?(p1 = nil, p2 = nil, flat = true) + mth = "OSut::#{__callee__}" + p1 = poly(p1, false, true, false) + p2 = poly(p2, false, true, false) + flat = true unless [true, false].include?(flat) + return false if p1.empty? + return false if p2.empty? - valid = p1.is_a?(OpenStudio::Point3dVector) || p1.is_a?(Array) - return mismatch("pts", p1, cl1, mth, DBG, p1) unless valid - return empty("pts", mth, ERR, p1) if p1.empty? + # Aligned, clockwise & convex points using transformation from 1st polygon. + t = OpenStudio::Transformation.alignFace(p1) + p1 = poly(p1, false, false, true, t, :cw) + p2 = poly(p2, false, false, true, t, :cw) + p1 = flatten(p1) if flat + p2 = flatten(p2) if flat + return false if p1.empty? + return false if p2.empty? - valid = p1.size == 3 || p1.size == 4 - iv = true if p1.size == 4 - return invalid("pts", mth, 1, DBG, p1) unless valid - return invalid("width", mth, 2, DBG, p1) unless w.respond_to?(:to_f) + return true if fits?(p1, p2) + return true if fits?(p2, p1) - w = w.to_f - return p1 if w < 0.0254 + area1 = OpenStudio.getArea(p1) + area2 = OpenStudio.getArea(p2) + return empty("points 1 area", mth, ERR, false) if area1.empty? + return empty("points 2 area", mth, ERR, false) if area2.empty? - v = v.to_i if v.respond_to?(:to_i) - v = 0 unless v.respond_to?(:to_i) - v = vrsn if v.zero? + area1 = area1.get + area2 = area2.get + union = OpenStudio.join(p1, p2, TOL2) + return false if union.empty? - p1.each { |x| return mismatch("p", x, cl, mth, ERR, p1) unless x.is_a?(cl) } + union = union.get + area = OpenStudio.getArea(union) + return false if area.empty? - unless v < 340 - # XY-plane transformation matrix ... needs to be clockwise for boost. - ft = OpenStudio::Transformation::alignFace(p1) - ft_pts = flatZ( (ft.inverse * p1) ) - return p1 if ft_pts.empty? + area = area.get + delta = area1 + area2 - area - cw = OpenStudio::pointInPolygon(ft_pts.first, ft_pts, TOL) - ft_pts = flatZ( (ft.inverse * p1).reverse ) unless cw - offset = OpenStudio.buffer(ft_pts, w, TOL) - return p1 if offset.empty? + if area > TOL + return false if (area - area1).abs < TOL + return false if (area - area2).abs < TOL + return false if delta.abs < TOL + return true if delta > TOL + end - offset = offset.get - offset = ft * offset if cw - offset = (ft * offset).reverse unless cw + false + end - pz = OpenStudio::Point3dVector.new - offset.each { |o| pz << OpenStudio::Point3d.new(o.x, o.y, o.z ) } + ## + # Generates offset vertices (by width) for a 3- or 4-sided, convex polygon. + # + # @param p1 [Set<OpenStudio::Point3d>] OpenStudio 3D points + # @param w [#to_f] offset width (min: 0.0254m) + # @param v [#to_i] OpenStudio SDK version, eg '321' for "v3.2.1" (optional) + # + # @return [OpenStudio::Point3dVector] offset points (see logs if unaltered) + def offset(p1 = nil, w = 0, v = 0) + mth = "OSut::#{__callee__}" + pts = poly(p1, true, true, false, true, :cw) + return invalid("points", mth, 1, DBG, p1) unless [3, 4].include?(pts.size) - return pz - else # brute force approach + mismatch("width", w, Numeric, mth) unless w.respond_to?(:to_f) + mismatch("version", v, Integer, mth) unless v.respond_to?(:to_i) + + vs = OpenStudio.openStudioVersion.split(".").join.to_i + iv = true if pts.size == 4 + v = v.to_i if v.respond_to?(:to_i) + v = -1 unless v.respond_to?(:to_i) + v = vs if v < 0 + w = w.to_f if w.respond_to?(:to_f) + w = 0 unless w.respond_to?(:to_f) + w = 0 if w < 0.0254 + + unless v < 340 + t = OpenStudio::Transformation.alignFace(p1) + offset = OpenStudio.buffer(pts, w, TOL) + return p1 if offset.empty? + + return to_p3Dv(t * offset.get.reverse) + else # brute force approach pz = {} pz[:A] = {} pz[:B] = {} pz[:C] = {} pz[:D] = {} if iv @@ -1688,86 +3153,352 @@ return vec end end ## - # Validate whether an OpenStudio planar surface is safe to process. + # Generates a ULC OpenStudio 3D point vector (a bounding box) that surrounds + # multiple (smaller) OpenStudio 3D point vectors. The generated, 4-point + # outline is optionally buffered (or offset). Frame and Divider frame widths + # are taken into account. # - # @param s [OpenStudio::Model::PlanarSurface] a surface + # @param a [Array] sets of OpenStudio 3D points + # @param bfr [Numeric] an optional buffer size (min: 0.0254m) + # @param flat [Bool] if points are to be pre-flattened (Z=0) # - # @return [Bool] true if valid surface - def surface_valid?(s = nil) + # @return [OpenStudio::Point3dVector] ULC outline (see logs if empty) + def outline(a = [], bfr = 0, flat = true) + mth = "OSut::#{__callee__}" + flat = true unless [true, false].include?(flat) + xMIN = nil + xMAX = nil + yMIN = nil + yMAX = nil + a2 = [] + out = OpenStudio::Point3dVector.new + cl = Array + return mismatch("array", a, cl, mth, DBG, out) unless a.is_a?(cl) + return empty("array", mth, DBG, out) if a.empty? + + mismatch("buffer", bfr, Numeric, mth) unless bfr.respond_to?(:to_f) + + bfr = bfr.to_f if bfr.respond_to?(:to_f) + bfr = 0 unless bfr.respond_to?(:to_f) + bfr = 0 if bfr < 0.0254 + vtx = poly(a.first) + t = OpenStudio::Transformation.alignFace(vtx) unless vtx.empty? + return out if vtx.empty? + + a.each do |pts| + points = poly(pts, false, true, false, t) + points = flatten(points) if flat + next if points.empty? + + a2 << points + end + + a2.each do |pts| + minX = pts.min_by(&:x).x + maxX = pts.max_by(&:x).x + minY = pts.min_by(&:y).y + maxY = pts.max_by(&:y).y + + # Consider frame width, if frame-and-divider-enabled sub surface. + if pts.respond_to?(:allowWindowPropertyFrameAndDivider) + fd = pts.windowPropertyFrameAndDivider + w = 0 + w = fd.get.frameWidth unless fd.empty? + + if w > TOL + minX -= w + maxX += w + minY -= w + maxY += w + end + end + + xMIN = minX if xMIN.nil? + xMAX = maxX if xMAX.nil? + yMIN = minY if yMIN.nil? + yMAX = maxY if yMAX.nil? + + xMIN = [xMIN, minX].min + xMAX = [xMAX, maxX].max + yMIN = [yMIN, minY].min + yMAX = [yMAX, maxY].max + end + + return negative("outline width", mth, DBG, out) if xMAX < xMIN + return negative("outline height", mth, DBG, out) if yMAX < yMIN + return zero("outline width", mth, DBG, out) if (xMIN - xMAX).abs < TOL + return zero("outline height", mth, DBG, out) if (yMIN - yMAX).abs < TOL + + # Generate ULC point 3D vector. + out << OpenStudio::Point3d.new(xMIN, yMAX, 0) + out << OpenStudio::Point3d.new(xMIN, yMIN, 0) + out << OpenStudio::Point3d.new(xMAX, yMIN, 0) + out << OpenStudio::Point3d.new(xMAX, yMAX, 0) + + # Apply buffer, apply ULC (options). + out = offset(out, bfr, 300) if bfr > 0.0254 + + to_p3Dv(t * out) + end + + ## + # Returns an array of OpenStudio space-specific surfaces that match criteria, + # e.g. exterior, north-east facing walls in hotel "lobby". Note 'sides' rely + # on space coordinates (not absolute model coordinates). And 'sides' are + # exclusive, not inclusive (e.g. walls strictly north-facing or strictly + # east-facing would not be returned if 'sides' holds [:north, :east]). + # + # @param spaces [Array<OpenStudio::Model::Space>] target spaces + # @param boundary [#to_s] OpenStudio outside boundary condition + # @param type [#to_s] OpenStudio surface type + # @param sides [Array<Symbols>] direction keys, e.g. :north (see OSut::SIDZ) + # + # @return [Array<OpenStudio::Model::Surface>] surfaces (may be empty) + def facets(spaces = [], boundary = "Outdoors", type = "Wall", sides = []) + return [] unless spaces.respond_to?(:&) + return [] unless sides.respond_to?(:&) + return [] if sides.empty? + + faces = [] + boundary = trim(boundary).downcase + type = trim(type).downcase + return [] if boundary.empty? + return [] if type.empty? + + # Keep valid sides. + sides = sides.select { |side| SIDZ.include?(side) } + return [] if sides.empty? + + spaces.each do |space| + return [] unless space.respond_to?(:setSpaceType) + + space.surfaces.each do |s| + next unless s.outsideBoundaryCondition.downcase == boundary + next unless s.surfaceType.downcase == type + + orientations = [] + orientations << :top if s.outwardNormal.z > TOL + orientations << :bottom if s.outwardNormal.z < -TOL + orientations << :north if s.outwardNormal.y > TOL + orientations << :east if s.outwardNormal.x > TOL + orientations << :south if s.outwardNormal.y < -TOL + orientations << :west if s.outwardNormal.x < -TOL + + faces << s if sides.all? { |o| orientations.include?(o) } + end + end + + faces + end + + ## + # Generates an OpenStudio 3D point vector of a composite floor "slab", a + # 'union' of multiple rectangular, horizontal floor "plates". Each plate + # must either share an edge with (or encompass or overlap) any of the + # preceding plates in the array. The generated slab may not be convex. + # + # @param [Array<Hash>] pltz individual floor plates, each holding: + # @option pltz [Numeric] :x left corner of plate origin (bird's eye view) + # @option pltz [Numeric] :y bottom corner of plate origin (bird's eye view) + # @option pltz [Numeric] :dx plate width (bird's eye view) + # @option pltz [Numeric] :dy plate depth (bird's eye view) + # @param z [Numeric] Z-axis coordinate + # + # @return [OpenStudio::Point3dVector] slab vertices (see logs if empty) + def genSlab(pltz = [], z = 0) mth = "OSut::#{__callee__}" - cl = OpenStudio::Model::PlanarSurface + slb = OpenStudio::Point3dVector.new + bkp = OpenStudio::Point3dVector.new + cl1 = Array + cl2 = Hash + cl3 = Numeric - return mismatch("surface", s, cl, mth, DBG, false) unless s.is_a?(cl) + # Input validation. + return mismatch("plates", pltz, cl1, mth, DBG, slb) unless pltz.is_a?(cl1) + return mismatch( "Z", z, cl3, mth, DBG, slb) unless z.is_a?(cl3) - id = s.nameString - size = s.vertices.size - last = size - 1 + pltz.each_with_index do |plt, i| + id = "plate # #{i+1} (index #{i})" - log(ERR, "#{id} #{size} vertices? need +3 (#{mth})") unless size > 2 - return false unless size > 2 + return mismatch(id, plt, cl1, mth, DBG, slb) unless plt.is_a?(cl2) + return hashkey( id, plt, :x, mth, DBG, slb) unless plt.key?(:x ) + return hashkey( id, plt, :y, mth, DBG, slb) unless plt.key?(:y ) + return hashkey( id, plt, :dx, mth, DBG, slb) unless plt.key?(:dx) + return hashkey( id, plt, :dy, mth, DBG, slb) unless plt.key?(:dy) - [0, last].each do |i| - v1 = s.vertices[i] - v2 = s.vertices[i + 1] unless i == last - v2 = s.vertices.first if i == last - vec = v2 - v1 - bad = vec.length < TOL + x = plt[:x ] + y = plt[:y ] + dx = plt[:dx] + dy = plt[:dy] - # As is, this comparison also catches collinear vertices (< 10mm apart) - # along an edge. Should avoid red-flagging such cases. TO DO. - log(ERR, "#{id}: < #{TOL}m (#{mth})") if bad - return false if bad + return mismatch("#{id} X", x, cl3, mth, DBG, slb) unless x.is_a?(cl3) + return mismatch("#{id} Y", y, cl3, mth, DBG, slb) unless y.is_a?(cl3) + return mismatch("#{id} dX", dx, cl3, mth, DBG, slb) unless dx.is_a?(cl3) + return mismatch("#{id} dY", dy, cl3, mth, DBG, slb) unless dy.is_a?(cl3) + return zero( "#{id} dX", mth, ERR, slb) if dx.abs < TOL + return zero( "#{id} dY", mth, ERR, slb) if dy.abs < TOL end - # Add as many extra tests as needed ... - true + # Join plates. + pltz.each_with_index do |plt, i| + id = "plate # #{i+1} (index #{i})" + x = plt[:x ] + y = plt[:y ] + dx = plt[:dx] + dy = plt[:dy] + + # Adjust X if dX < 0. + x -= -dx if dx < 0 + dx = -dx if dx < 0 + + # Adjust Y if dY < 0. + y -= -dy if dy < 0 + dy = -dy if dy < 0 + + vtx = [] + vtx << OpenStudio::Point3d.new(x + dx, y + dy, 0) + vtx << OpenStudio::Point3d.new(x + dx, y, 0) + vtx << OpenStudio::Point3d.new(x, y, 0) + vtx << OpenStudio::Point3d.new(x, y + dy, 0) + + if slb.empty? + slb = vtx + else + slab = OpenStudio.join(slb, vtx, TOL2) + slb = slab.get unless slab.empty? + return invalid(id, mth, 0, ERR, bkp) if slab.empty? + end + end + + # Once joined, re-adjust Z-axis coordinates. + unless z.zero? + vtx = OpenStudio::Point3dVector.new + slb.each { |pt| vtx << OpenStudio::Point3d.new(pt.x, pt.y, z) } + slb = vtx + end + + slb end ## - # Add sub surfaces (e.g. windows, doors, skylights) to surface. + # Returns outdoor-facing, space-(related) roof/ceiling surfaces. These + # include outdoor-facing roof/ceilings of the space per se, as well as + # any outside-facing roof/ceiling surface of an unoccupied space + # immediately above (e.g. a plenum) overlapping any of the roof/ceilings + # of the space itself. # - # @param model [OpenStudio::Model::Model] a model + # @param space [OpenStudio::Model::Space] a space + # + # @return [Array<OpenStudio::Model::Surface>] surfaces (see logs if empty) + def getRoofs(space = nil) + mth = "OSut::#{__callee__}" + cl = OpenStudio::Model::Space + return mismatch("space", space, cl, mth, DBG, []) unless space.is_a?(cl) + + roofs = space.surfaces # outdoor-facing roofs of the space + clngs = space.surfaces # surface-facing ceilings of the space + + roofs = roofs.select {|s| s.surfaceType.downcase == "roofceiling"} + roofs = roofs.select {|s| s.outsideBoundaryCondition.downcase == "outdoors"} + + clngs = clngs.select {|s| s.surfaceType.downcase == "roofceiling"} + clngs = clngs.select {|s| s.outsideBoundaryCondition.downcase == "surface"} + + clngs.each do |ceiling| + floor = ceiling.adjacentSurface + next if floor.empty? + + other = floor.get.space + next if other.empty? + + rufs = other.get.surfaces + + rufs = rufs.select {|s| s.surfaceType.downcase == "roofceiling"} + rufs = rufs.select {|s| s.outsideBoundaryCondition.downcase == "outdoors"} + next if rufs.empty? + + # Only keep track of "other" roof(s) that "overlap" ceiling below. + rufs.each do |ruf| + next unless overlaps?(ceiling, ruf) + + roofs << ruf unless roofs.include?(ruf) + end + end + + roofs + end + + ## + # Adds sub surfaces (e.g. windows, doors, skylights) to surface. + # # @param s [OpenStudio::Model::Surface] a model surface - # @param subs [Array] requested sub surface attributes - # @param clear [Bool] remove current sub surfaces if true - # @param bfr [Double] safety buffer (m), when ~aligned along other edges + # @param [Array<Hash>] subs requested attributes + # @option subs [#to_s] :id identifier e.g. "Window 007" + # @option subs [#to_s] :type ("FixedWindow") OpenStudio subsurface type + # @option subs [#to_i] :count (1) number of individual subs per array + # @option subs [#to_i] :multiplier (1) OpenStudio subsurface multiplier + # @option subs [#frameWidth] :frame (nil) OpenStudio frame & divider object + # @option subs [#isFenestration] :assembly (nil) OpenStudio construction + # @option subs [#to_f] :ratio e.g. %FWR [0.0, 1.0] + # @option subs [#to_f] :head (OSut::HEAD) e.g. door height (incl frame) + # @option subs [#to_f] :sill (OSut::SILL) e.g. window sill (incl frame) + # @option subs [#to_f] :height sill-to-head height + # @option subs [#to_f] :width e.g. door width + # @option subs [#to_f] :offset left-right centreline dX e.g. between doors + # @option subs [#to_f] :centreline left-right dX (sub/array vs base) + # @option subs [#to_f] :r_buffer gap between sub/array and right corner + # @option subs [#to_f] :l_buffer gap between sub/array and left corner + # @param clear [Bool] whether to remove current sub surfaces + # @param bfr [#to_f] safety buffer, to maintain near other edges # - # @return [Bool] true if successful (check for logged messages if failures) - def addSubs(model = nil, s = nil, subs = [], clear = false, bfr = 0.005) + # @return [Bool] whether addition is successful + # @return [false] if invalid input (see logs) + def addSubs(s = nil, subs = [], clear = false, bfr = 0.005) mth = "OSut::#{__callee__}" v = OpenStudio.openStudioVersion.split(".").join.to_i - cl1 = OpenStudio::Model::Model - cl2 = OpenStudio::Model::Surface - cl3 = Array - cl4 = Hash - cl5 = Numeric + cl1 = OpenStudio::Model::Surface + cl2 = Array + cl3 = Hash min = 0.050 # minimum ratio value ( 5%) max = 0.950 # maximum ratio value (95%) no = false # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # # Exit if mismatched or invalid argument classes. - return mismatch("model", model, cl1, mth, DBG, no) unless model.is_a?(cl1) - return mismatch("surface", s, cl2, mth, DBG, no) unless s.is_a?(cl2) - return mismatch("subs", subs, cl3, mth, DBG, no) unless subs.is_a?(cl3) - return no unless surface_valid?(s) + return mismatch("surface", s, cl2, mth, DBG, no) unless s.is_a?(cl1) + return mismatch("subs", subs, cl3, mth, DBG, no) unless subs.is_a?(cl2) + return empty("surface points", mth, DBG, no) if poly(s).empty? # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # # Clear existing sub surfaces if requested. nom = s.nameString + mdl = s.model - unless clear == true || clear == false + unless [true, false].include?(clear) log(WRN, "#{nom}: Keeping existing sub surfaces (#{mth})") clear = false end s.subSurfaces.map(&:remove) if clear # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + # Ensure minimum safety buffer. + if bfr.respond_to?(:to_f) + bfr = bfr.to_f + return negative("safety buffer", mth, ERR, no) if bfr < 0 + + msg = "Safety buffer < 5mm may generate invalid geometry (#{mth})" + log(WRN, msg) if bfr < 0.005 + else + log(ERR, "Setting safety buffer to 5mm (#{mth})") + bfr = 0.005 + end + + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # # Allowable sub surface types ... & Frame&Divider enabled # - "FixedWindow" | true # - "OperableWindow" | true # - "Door" | false # - "GlassDoor" | true @@ -1778,52 +3509,41 @@ type = "FixedWindow" types = OpenStudio::Model::SubSurface.validSubSurfaceTypeValues stype = s.surfaceType # Wall, RoofCeiling or Floor # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # - # Fetch transform, as if host surface vertices were to "align", i.e.: - # - rotated/tilted ... then flattened along XY plane - # - all Z-axis coordinates ~= 0 - # - vertices with the lowest X-axis values are "aligned" along X-axis (0) - # - vertices with the lowest Z-axis values are "aligned" along Y-axis (0) - # - Z-axis values are represented as Y-axis values - tr = OpenStudio::Transformation.alignFace(s.vertices) + t = OpenStudio::Transformation.alignFace(s.vertices) + max_x = width(s) + max_y = height(s) + mid_x = max_x / 2 + mid_y = max_y / 2 - # Aligned vertices of host surface, and fetch attributes. - aligned = tr.inverse * s.vertices - max_x = aligned.max_by(&:x).x - max_y = aligned.max_by(&:y).y - mid_x = max_x / 2 - mid_y = max_y / 2 - # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # # Assign default values to certain sub keys (if missing), +more validation. subs.each_with_index do |sub, index| - return mismatch("sub", sub, cl4, mth, DBG, no) unless sub.is_a?(cl4) + return mismatch("sub", sub, cl4, mth, DBG, no) unless sub.is_a?(cl3) # Required key:value pairs (either set by the user or defaulted). - sub[:id ] = "" unless sub.key?(:id ) # "Window 007" - sub[:type ] = type unless sub.key?(:type ) # "FixedWindow" - sub[:count ] = 1 unless sub.key?(:count ) # for an array - sub[:multiplier] = 1 unless sub.key?(:multiplier) - sub[:frame ] = nil unless sub.key?(:frame ) # frame/divider - sub[:assembly ] = nil unless sub.key?(:assembly ) # construction + sub[:frame ] = nil unless sub.key?(:frame ) + sub[:assembly ] = nil unless sub.key?(:assembly ) + sub[:count ] = 1 unless sub.key?(:count ) + sub[:multiplier] = 1 unless sub.key?(:multiplier) + sub[:id ] = "" unless sub.key?(:id ) + sub[:type ] = type unless sub.key?(:type ) + sub[:type ] = trim(sub[:type]) + sub[:id ] = trim(sub[:id]) + sub[:type ] = type if sub[:type].empty? + sub[:id ] = "OSut|#{nom}|#{index}" if sub[:id ].empty? + sub[:count ] = 1 unless sub[:count ].respond_to?(:to_i) + sub[:multiplier] = 1 unless sub[:multiplier].respond_to?(:to_i) + sub[:count ] = sub[:count ].to_i + sub[:multiplier] = sub[:multiplier].to_i + sub[:count ] = 1 if sub[:count ] < 1 + sub[:multiplier] = 1 if sub[:multiplier] < 1 - # Optional key:value pairs. - # sub[:ratio ] # e.g. %FWR - # sub[:head ] # e.g. std 80" door + frame/buffers (+ m) - # sub[:sill ] # e.g. std 30" sill + frame/buffers (+ m) - # sub[:height ] # any sub surface height, below "head" (+ m) - # sub[:width ] # e.g. 1.200 m - # sub[:offset ] # if array (+ m) - # sub[:centreline] # left or right of base surface centreline (+/- m) - # sub[:r_buffer ] # buffer between sub/array and right-side corner (+ m) - # sub[:l_buffer ] # buffer between sub/array and left-side corner (+ m) + id = sub[:id] - sub[:id] = "#{nom}|#{index}" if sub[:id].empty? - id = sub[:id] - # If sub surface type is invalid, log/reset. Additional corrections may # be enabled once a sub surface is actually instantiated. unless types.include?(sub[:type]) log(WRN, "Reset invalid '#{id}' type to '#{type}' (#{mth})") sub[:type] = type @@ -1853,18 +3573,21 @@ log(WRN, "Skip invalid '#{id}' construction (#{mth})") sub[:assembly] = nil end end - # Log/reset negative numerical values. Set ~0 values to 0. + # Log/reset negative float values. Set ~0.0 values to 0.0. sub.each do |key, value| - next if key == :id + next if key == :count + next if key == :multiplier next if key == :type + next if key == :id next if key == :frame next if key == :assembly - return mismatch(key, value, cl5, mth, DBG, no) unless value.is_a?(cl5) + ok = value.respond_to?(:to_f) + return mismatch(key, value, Float, mth, DBG, no) unless ok next if key == :centreline negative(key, mth, WRN) if value < 0 value = 0.0 if value.abs < TOL end @@ -1905,13 +3628,13 @@ min_rjamb = buffers + glass max_rjamb = max_x - buffer max_height = max_y - buffers max_width = max_x - buffers - # Default sub surface "head" & "sill" height (unless user-specified). - typ_head = HEAD # standard 80" door - typ_sill = SILL # standard 30" window sill + # Default sub surface "head" & "sill" height, unless user-specified. + typ_head = HEAD + typ_sill = SILL if sub.key?(:ratio) typ_head = mid_y * (1 + sub[:ratio]) if sub[:ratio] > 0.75 typ_head = mid_y * (1 + sub[:ratio]) unless stype.downcase == "wall" typ_sill = mid_y * (1 - sub[:ratio]) if sub[:ratio] > 0.75 @@ -2049,22 +3772,26 @@ end # Log/reset "width" if beyond min/max. if sub.key?(:width) unless sub[:width].between?(glass, max_width) - sub[:width] = glass if sub[:width] < glass - sub[:width] = max_width if sub[:width] > max_width + sub[:width] = glass if sub[:width] < glass + sub[:width] = max_width if sub[:width] > max_width log(WRN, "Reset '#{id}' width to #{sub[:width]} m (#{mth})") end end - # Log/reset "count" if < 1. - if sub.key?(:count) + # Log/reset "count" if < 1 (or not an Integer) + if sub[:count].respond_to?(:to_i) + sub[:count] = sub[:count].to_i + if sub[:count] < 1 sub[:count] = 1 log(WRN, "Reset '#{id}' count to #{sub[:count]} (#{mth})") end + else + sub[:count] = 1 end sub[:count] = 1 unless sub.key?(:count) # Log/reset if left-sided buffer under min jamb position. @@ -2219,25 +3946,25 @@ # Initialize left-side X-axis coordinate of only/first sub. pos = x0 + frame # Generate sub(s). sub[:count].times do |i| - name = "#{id}:#{i}" + name = "#{id}|#{i}" fr = 0 fr = sub[:frame].frameWidth if sub[:frame] vec = OpenStudio::Point3dVector.new vec << OpenStudio::Point3d.new(pos, sub[:head], 0) vec << OpenStudio::Point3d.new(pos, sub[:sill], 0) vec << OpenStudio::Point3d.new(pos + sub[:width], sub[:sill], 0) vec << OpenStudio::Point3d.new(pos + sub[:width], sub[:head], 0) - vec = tr * vec + vec = t * vec # Log/skip if conflict between individual sub and base surface. vc = vec vc = offset(vc, fr, 300) if fr > 0 - ok = fits?(vc, s.vertices, name, nom) + ok = fits?(vc, s) log(ERR, "Skip '#{name}': won't fit in '#{nom}' (#{mth})") unless ok break unless ok # Log/skip if conflicts with existing subs (even if same array). s.subSurfaces.each do |sb| @@ -2245,18 +3972,18 @@ fd = sb.windowPropertyFrameAndDivider fr = 0 if fd.empty? fr = fd.get.frameWidth unless fd.empty? vk = sb.vertices vk = offset(vk, fr, 300) if fr > 0 - oops = overlaps?(vc, vk, name, nome) + oops = overlaps?(vc, vk) log(ERR, "Skip '#{name}': overlaps '#{nome}' (#{mth})") if oops ok = false if oops break if oops end break unless ok - sb = OpenStudio::Model::SubSurface.new(vec, model) + sb = OpenStudio::Model::SubSurface.new(vec, mdl) sb.setName(name) sb.setSubSurfaceType(sub[:type]) sb.setConstruction(sub[:assembly]) if sub[:assembly] ok = sb.allowWindowPropertyFrameAndDivider sb.setWindowPropertyFrameAndDivider(sub[:frame]) if sub[:frame] && ok