diff --git a/lib/measures/tbd/measure.xml b/lib/measures/tbd/measure.xml index 5d17eae..f9048ef 100644 --- a/lib/measures/tbd/measure.xml +++ b/lib/measures/tbd/measure.xml @@ -3,8 +3,8 @@ 3.1 tbd_measure 8890787b-8c25-4dc8-8641-b6be1b6c2357 - 7a2d773a-7a50-4c69-aa1f-93ed796e9ace - 2025-08-15T13:39:31Z + 0e1528a0-a176-4d46-82d9-d2da40c0abd7 + 2025-09-11T11:27:14Z 99772807 TBDMeasure Thermal Bridging and Derating - TBD @@ -499,7 +499,7 @@ geo.rb rb resource - EF3BA8F7 + 5AB24CFB geometry.rb @@ -523,7 +523,7 @@ psi.rb rb resource - 71AED953 + 29905280 tbd.rb @@ -541,13 +541,13 @@ ua.rb rb resource - 022F6D10 + 0CC24D82 utils.rb rb resource - 3CD8019A + 6940D006 version.rb diff --git a/lib/measures/tbd/resources/geo.rb b/lib/measures/tbd/resources/geo.rb index 7dd2a6f..34e8f07 100644 --- a/lib/measures/tbd/resources/geo.rb +++ b/lib/measures/tbd/resources/geo.rb @@ -310,23 +310,24 @@ def properties(surface = nil, argh = {}) end unless surface.construction.empty? - construction = surface.construction.get.to_LayeredConstruction + lc = surface.construction.get.to_LayeredConstruction - unless construction.empty? - construction = construction.get - lyr = insulatingLayer(construction) - lyr[:index] = nil unless lyr[:index].is_a?(Numeric) - lyr[:index] = nil unless lyr[:index] >= 0 - lyr[:index] = nil unless lyr[:index] < construction.layers.size + unless lc.empty? + lc = lc.get + lyr = insulatingLayer(lc) - if lyr[:index] - surf[:construction] = construction + if lyr[:index].is_a?(Integer) && lyr[:index].between?(0, lc.numLayers - 1) + surf[:construction] = lc # index: ... of layer/material (to derate) within construction # ltype: either :massless (RSi) or :standard (k + d) # r : initial RSi value of the indexed layer to derate surf[:index] = lyr[:index] surf[:ltype] = lyr[:type ] surf[:r ] = lyr[:r ] + else + surf[:index] = nil + surf[:ltype] = nil + surf[:r ] = 0.0 end end end diff --git a/lib/measures/tbd/resources/psi.rb b/lib/measures/tbd/resources/psi.rb index 8075770..d80874c 100644 --- a/lib/measures/tbd/resources/psi.rb +++ b/lib/measures/tbd/resources/psi.rb @@ -1355,103 +1355,103 @@ def inputs(s = {}, e = {}, argh = {}) ## # Thermally derates insulating material within construction. # - # @param id [#to_s] surface identifier + # @param id [#to_sym] surface identifier # @param [Hash] s TBD surface parameters - # @option s [#to_f] :heatloss heat loss from major thermal bridging, in W/K - # @option s [#to_f] :net surface net area, in m2 + # @option s [Numeric] :heatloss heat loss from major thermal bridging, in W/K + # @option s [Numeric] :net surface net area, in m2 # @option s [:massless, :standard] :ltype indexed layer type - # @option s [#to_i] :index deratable construction layer index - # @option s [#to_f] :r deratable layer Rsi-factor, in m2•K/W + # @option s [Integer] :index deratable construction layer index + # @option s [Numeric] :r deratable layer Rsi-factor, in m2•K/W # @param lc [OpenStudio::Model::LayeredConstruction] a layered construction # - # @return [OpenStudio::Model::Material] derated (cloned) material + # @return [OpenStudio::Model::OpaqueMaterial] derated (cloned) material # @return [nil] if invalid input (see logs) def derate(id = "", s = {}, lc = nil) mth = "TBD::#{__callee__}" m = nil - id = trim(id) kys = [:heatloss, :net, :ltype, :index, :r] - ck1 = s.is_a?(Hash) - ck2 = lc.is_a?(OpenStudio::Model::LayeredConstruction) - return mismatch("id" , id, cl6, mth) if id.empty? - return mismatch("#{id} surface" , s , cl1, mth) unless ck1 - return mismatch("#{id} construction", lc, cl2, mth) unless ck2 + cl = OpenStudio::Model::LayeredConstruction + return mismatch("lc", lc, cl, mth) unless lc.respond_to?(NS) + return mismatch("id", id, String, mth) unless id.respond_to?(:to_sym) + + id = trim(id) + nom = lc.nameString + return invalid("id", mth, 1) if id.empty? + return mismatch(nom, lc, cl, mth) unless lc.is_a?(cl) + return mismatch("#{nom} surface", s, Hash, mth) unless s.is_a?(Hash) + + if nom.downcase.include?(" tbd") + log(WRN, "Won't derate '#{nom}': tagged as derated (#{mth})") + return m + end kys.each do |k| tag = "#{id} #{k}" - return hashkey(tag, s, k, mth, ERR) unless s.key?(k) + return hashkey(tag, s, k, mth) unless s.key?(k) case k - when :heatloss - return mismatch(tag, s[k], Numeric, mth) unless s[k].respond_to?(:to_f) - return zero(tag, mth, WRN) if s[k].to_f.abs < 0.001 - when :net, :r - return mismatch(tag, s[k], Numeric, mth) unless s[k].respond_to?(:to_f) - return negative(tag, mth, 2, ERR) if s[k].to_f < 0 - return zero(tag, mth, WRN) if s[k].to_f.abs < 0.001 - when :index - return mismatch(tag, s[k], Numeric, mth) unless s[k].respond_to?(:to_i) - return negative(tag, mth, 2, ERR) if s[k].to_f < 0 - else # :ltype + when :ltype next if [:massless, :standard].include?(s[k]) - return invalid(tag, mth, 2, ERR) - end - end + return invalid(tag, mth, 2) + when :index + return mismatch(tag, s[k], Integer, mth) unless s[k].is_a?(Integer) + return invalid(tag, mth, 2) unless s[k].between?(0, lc.numLayers - 1) + else + return mismatch(tag, s[k], Numeric, mth) unless s[k].is_a?(Numeric) + next if k == :heatloss - if lc.nameString.downcase.include?(" tbd") - log(WRN, "Won't derate '#{id}': tagged as derated (#{mth})") - return m + return negative(tag, mth, 2) if s[k] < 0 + return zero(tag, mth) if s[k].abs < 0.001 + end end model = lc.model - ltype = s[:ltype ] - index = s[:index ].to_i - net = s[:net ].to_f - r = s[:r ].to_f - u = s[:heatloss].to_f / net + ltype = s[:ltype] + index = s[:index] + net = s[:net] + r = s[:r] + u = s[:heatloss] / net loss = 0 - de_u = 1 / r + u # derated U - de_r = 1 / de_u # derated R + de_u = 1 / r + u # derated insulating material U + de_r = 1 / de_u # derated insulating material R if ltype == :massless - m = lc.getLayer(index).to_MasslessOpaqueMaterial + m = lc.getLayer(index).to_MasslessOpaqueMaterial return invalid("#{id} massless layer?", mth, 0) if m.empty? - m = m.get - up = "" - up = "uprated " if m.nameString.downcase.include?(" uprated") - m = m.clone(model).to_MasslessOpaqueMaterial.get - m.setName("#{id} #{up}m tbd") - de_r = 0.001 unless de_r > 0.001 - loss = (de_u - 1 / de_r) * net unless de_r > 0.001 - m.setThermalResistance(de_r) + + m = m.get + up = m.nameString.downcase.include?(" uprated") ? "uprated " : "" + m = m.clone(model).to_MasslessOpaqueMaterial.get + m.setName("#{id} #{up}m tbd") + + de_r = RMIN unless de_r > RMIN + loss = (de_u - 1 / de_r) * net unless de_r > RMIN + + unless m.setThermalResistance(de_r) + return invalid("Can't derate #{id}: RSi#{de_r.round(2)}", mth) + end else - m = lc.getLayer(index).to_StandardOpaqueMaterial + m = lc.getLayer(index).to_StandardOpaqueMaterial return invalid("#{id} standard layer?", mth, 0) if m.empty? - m = m.get - up = "" - up = "uprated " if m.nameString.downcase.include?(" uprated") - m = m.clone(model).to_StandardOpaqueMaterial.get - m.setName("#{id} #{up}m tbd") - k = m.thermalConductivity - - if de_r > 0.001 - d = de_r * k - - unless d > 0.003 - d = 0.003 - k = d / de_r - k = 3 unless k < 3 - loss = (de_u - k / d) * net unless k < 3 - end - else # de_r < 0.001 m2•K/W - d = 0.001 * k - d = 0.003 unless d > 0.003 - k = d / 0.001 unless d > 0.003 - loss = (de_u - k / d) * net + + m = m.get + up = m.nameString.downcase.include?(" uprated") ? "uprated " : "" + m = m.clone(model).to_StandardOpaqueMaterial.get + m.setName("#{id} #{up}m tbd") + + d = m.thickness + k = (d / de_r).clamp(KMIN, KMAX) + d = (k * de_r).clamp(DMIN, DMAX) + + loss = (de_u - k / d) * net unless d / k > RMIN + + unless m.setThermalConductivity(k) + return invalid("Can't derate #{id}: K#{k.round(3)}", mth) end - m.setThickness(d) - m.setThermalConductivity(k) + unless m.setThickness(d) + return invalid("Can't derate #{id}: #{(d*1000).to_i}mm", mth) + end end if m && loss > TOL @@ -1941,14 +1941,38 @@ def process(model = nil, argh = {}) ids = windows.keys + doors.keys + skylights.keys end - unless ids.include?(i) - log(ERR, "Orphaned subsurface #{i} (mth)") + adj = nil + + unless ids.include?(i) # adjacent sub surface? + sb = model.getSubSurfaceByName(i) + + if sb.empty? + log(DBG, "Orphaned subsurface #{i} (#{mth})?") + else + sb = sb.get + adj = sb.adjacentSubSurface + + if adj.empty? + log(DBG, "Orphaned sub #{i} (#{mth})?") + end + end + next end - window = windows.key?(i) ? windows[i] : {} - door = doors.key?(i) ? doors[i] : {} - skylight = skylights.key?(i) ? skylights[i] : {} + if adj + window = windows.key?(i) ? windows[i] : {} + door = doors.key?(i) ? doors[i] : {} + skylight = skylights.key?(i) ? skylights[i] : {} + else + window = windows.key?(i) ? windows[i] : {} + door = doors.key?(i) ? doors[i] : {} + skylight = skylights.key?(i) ? skylights[i] : {} + end + + # window = windows.key?(i) ? windows[i] : {} + # door = doors.key?(i) ? doors[i] : {} + # skylight = skylights.key?(i) ? skylights[i] : {} sub = window unless window.empty? sub = door unless door.empty? @@ -2939,12 +2963,12 @@ def process(model = nil, argh = {}) up = argh[:uprate_walls] || argh[:uprate_roofs] || argh[:uprate_floors] uprate(model, tbd[:surfaces], argh) if up - # Derated (cloned) constructions are unique to each deratable surface. - # Unique construction names are prefixed with the surface name, - # and suffixed with " tbd", indicating that the construction is - # henceforth thermally derated. The " tbd" expression is also key in - # avoiding inadvertent derating - TBD will not derate constructions - # (or rather layered materials) having " tbd" in their OpenStudio name. + # A derated (cloned) construction and (cloned) insulating layer are unique + # to each deratable surface. Unique construction and material names are + # prefixed with the surface name, and suffixed with " tbd", indicating that + # the construction is henceforth thermally derated. The " tbd" expression + # is also key in avoiding inadvertent sequential derating - TBD will not + # derate a construction/material pair having " tbd" in their OpenStudio name. tbd[:surfaces].each do |id, surface| next unless surface.key?(:construction) next unless surface.key?(:index) @@ -3138,9 +3162,9 @@ def exit(runner = nil, argh = {}) argh[:uprate_walls ] = false unless argh.key?(:uprate_walls ) argh[:uprate_roofs ] = false unless argh.key?(:uprate_roofs ) argh[:uprate_floors] = false unless argh.key?(:uprate_floors) - argh[:wall_ut ] = 5.678 unless argh.key?(:wall_ut ) - argh[:roof_ut ] = 5.678 unless argh.key?(:roof_ut ) - argh[:floor_ut ] = 5.678 unless argh.key?(:floor_ut ) + argh[:wall_ut ] = UMAX unless argh.key?(:wall_ut ) + argh[:roof_ut ] = UMAX unless argh.key?(:roof_ut ) + argh[:floor_ut ] = UMAX unless argh.key?(:floor_ut ) argh[:wall_option ] = "" unless argh.key?(:wall_option ) argh[:roof_option ] = "" unless argh.key?(:roof_option ) argh[:floor_option ] = "" unless argh.key?(:floor_option ) diff --git a/lib/measures/tbd/resources/ua.rb b/lib/measures/tbd/resources/ua.rb index 41c1d5b..bfbedf9 100644 --- a/lib/measures/tbd/resources/ua.rb +++ b/lib/measures/tbd/resources/ua.rb @@ -54,12 +54,12 @@ def uo(model = nil, lc = nil, id = "", hloss = 0.0, film = 0.0, ut = 0.0) lyr[:index] = nil unless lyr[:index].is_a?(Numeric) lyr[:index] = nil unless lyr[:index] >= 0 lyr[:index] = nil unless lyr[:index] < lc.layers.size - return invalid("#{id} layer index", mth, 3, ERR, res) unless lyr[:index] + return invalid("#{id} layer index", mth, 3, WRN, res) unless lyr[:index] return zero("#{id}: heatloss" , mth, WRN, res) unless hloss > TOL return zero("#{id}: films" , mth, WRN, res) unless film > TOL - return zero("#{id}: Ut" , mth, WRN, res) unless ut > TOL - return invalid("#{id}: Ut" , mth, 6, WRN, res) unless ut < 5.678 - return zero("#{id}: net area (m2)", mth, ERR, res) unless area > TOL + return zero("#{id}: Ut" , mth, WRN, res) unless ut > UMIN + return invalid("#{id}: Ut" , mth, 6, WRN, res) unless ut < UMAX + return zero("#{id}: net area (m2)", mth, WRN, res) unless area > TOL # First, calculate initial layer RSi to initially meet Ut target. rt = 1 / ut # target construction Rt @@ -71,56 +71,51 @@ def uo(model = nil, lc = nil, id = "", hloss = 0.0, film = 0.0, ut = 0.0) u_psi = hloss / area # from psi+khi new_u -= u_psi # uprated layer USi to counter psi+khi new_r = 1 / new_u # uprated layer RSi to counter psi+khi - return zero("#{id}: new Rsi", mth, ERR, res) unless new_r > 0.001 + return zero("#{id}: new Rsi", mth, WRN, res) unless new_r > RMIN if lyr[:type] == :massless - m = lc.getLayer(lyr[:index]).to_MasslessOpaqueMaterial - return invalid("#{id} massless layer?", mth, 0, DBG, res) if m.empty? - - m = m.get.clone(model).to_MasslessOpaqueMaterial.get - m.setName("#{id} uprated") - new_r = 0.001 unless new_r > 0.001 - loss = (new_u - 1 / new_r) * area unless new_r > 0.001 - m.setThermalResistance(new_r) - else # type == :standard - m = lc.getLayer(lyr[:index]).to_StandardOpaqueMaterial - return invalid("#{id} standard layer?", mth, 0, DBG, res) if m.empty? - - m = m.get.clone(model).to_StandardOpaqueMaterial.get - m.setName("#{id} uprated") - k = m.thermalConductivity - - if new_r > 0.001 - d = new_r * k - - unless d > 0.003 - d = 0.003 - k = d / new_r - k = 3.0 unless k < 3.0 - loss = (new_u - k / d) * area unless k < 3.0 - end - else # new_r < 0.001 m2•K/W - d = 0.001 * k - d = 0.003 unless d > 0.003 - k = d / 0.001 unless d > 0.003 - loss = (new_u - k / d) * area + m = lc.getLayer(lyr[:index]).to_MasslessOpaqueMaterial + return invalid("#{id} massless layer?", mth, 0, DBG, res) if m.empty? + + m = m.get.clone(model).to_MasslessOpaqueMaterial.get + m.setName("#{id} uprated") + + new_r = RMIN unless new_r > RMIN + loss = (new_u - 1 / new_r) * area unless new_r > RMIN + + unless m.setThermalResistance(new_r) + return invalid("Can't uprate #{id}: RSi#{new_r.round(2)}", mth, 0, DBG, res) end + else + m = lc.getLayer(lyr[:index]).to_StandardOpaqueMaterial + return invalid("#{id} standard layer?", mth, 0, DBG, res) if m.empty? - if m.setThickness(d) - m.setThermalConductivity(k) - else - return invalid("Can't uprate #{id}: #{d} > 3m", mth, 0, ERR, res) + m = m.get.clone(model).to_StandardOpaqueMaterial.get + m.setName("#{id} uprated") + + d = m.thickness + k = (d / new_r).clamp(KMIN, KMAX) + d = (k * new_r).clamp(DMIN, DMAX) + + loss = (new_u - k / d) * area unless d / k > RMIN + + unless m.setThermalConductivity(k) + return invalid("Can't uprate #{id}: K#{k.round(3)}", mth, 0, DBG, res) + end + + unless m.setThickness(d) + return invalid("Can't uprate #{id}: #{(d*1000).to_i}mm", mth, 0, DBG, res) end end - return invalid("Can't ID insulating layer", mth, 0, ERR, res) unless m + return invalid("Can't ID insulating layer", mth, 0, DBG, res) unless m lc.setLayer(lyr[:index], m) uo = 1 / rsi(lc, film) if loss > TOL h_loss = format "%.3f", loss - return invalid("Can't assign #{h_loss} W/K to #{id}", mth, 0, ERR, res) + return invalid("Can't assign #{h_loss} W/K to #{id}", mth, 0, DBG, res) end res[:uo] = uo @@ -145,9 +140,9 @@ def uo(model = nil, lc = nil, id = "", hloss = 0.0, film = 0.0, ut = 0.0) # @option argh [Bool] :uprate_walls (false) whether to uprate walls # @option argh [Bool] :uprate_roofs (false) whether to uprate roofs # @option argh [Bool] :uprate_floors (false) whether to uprate floors - # @option argh [#to_f] :wall_ut (5.678) uprated wall Usi-factor target - # @option argh [#to_f] :roof_ut (5.678) uprated roof Usi-factor target - # @option argh [#to_f] :floor_ut (5.678) uprated floor Usi-factor target + # @option argh [#to_f] :wall_ut (UMAX) uprated wall Usi-factor target + # @option argh [#to_f] :roof_ut (UMAX) uprated roof Usi-factor target + # @option argh [#to_f] :floor_ut (UMAX) uprated floor Usi-factor target # @option argh [#to_s] :wall_option ("") construction to uprate (or "all") # @option argh [#to_s] :roof_option ("") construction to uprate (or "all") # @option argh [#to_s] :floor_option ("") construction to uprate (or "all") @@ -172,9 +167,9 @@ def uprate(model = nil, s = {}, argh = {}) argh[:uprate_walls ] = false unless argh.key?(:uprate_walls) argh[:uprate_roofs ] = false unless argh.key?(:uprate_roofs) argh[:uprate_floors] = false unless argh.key?(:uprate_floors) - argh[:wall_ut ] = 5.678 unless argh.key?(:wall_ut) - argh[:roof_ut ] = 5.678 unless argh.key?(:roof_ut) - argh[:floor_ut ] = 5.678 unless argh.key?(:floor_ut) + argh[:wall_ut ] = UMAX unless argh.key?(:wall_ut) + argh[:roof_ut ] = UMAX unless argh.key?(:roof_ut) + argh[:floor_ut ] = UMAX unless argh.key?(:floor_ut) argh[:wall_option ] = "" unless argh.key?(:wall_option) argh[:roof_option ] = "" unless argh.key?(:roof_option) argh[:floor_option ] = "" unless argh.key?(:floor_option) @@ -197,7 +192,7 @@ def uprate(model = nil, s = {}, argh = {}) groups.each do |type, g| next unless g[:up] next unless g[:ut].is_a?(Numeric) - next unless g[:ut] < 5.678 + next unless g[:ut] < UMAX next if g[:ut] < 0 typ = type @@ -211,7 +206,7 @@ def uprate(model = nil, s = {}, argh = {}) all = tout.include?(op) if g[:op].empty? - log(ERR, "Construction (#{type}) to uprate? (#{mth})") + log(WRN, "Construction (#{type}) to uprate? (#{mth})") elsif all s.each do |nom, surface| next unless surface.key?(:deratable ) @@ -247,11 +242,11 @@ def uprate(model = nil, s = {}, argh = {}) else id = g[:op] lc = model.getConstructionByName(id) - log(ERR, "Construction '#{id}'? (#{mth})") if lc.empty? + log(WRN, "Construction '#{id}'? (#{mth})") if lc.empty? next if lc.empty? lc = lc.get.to_LayeredConstruction - log(ERR, "'#{id}' layered construction? (#{mth})") if lc.empty? + log(WRN, "'#{id}' layered construction? (#{mth})") if lc.empty? next if lc.empty? lc = lc.get @@ -282,7 +277,7 @@ def uprate(model = nil, s = {}, argh = {}) end if coll.empty? - log(ERR, "No #{type} construction to uprate - skipping (#{mth})") + log(WRN, "No #{type} construction to uprate - skipping (#{mth})") next elsif lc # Valid layered construction - good to uprate! @@ -291,7 +286,7 @@ def uprate(model = nil, s = {}, argh = {}) lyr[:index] = nil unless lyr[:index] >= 0 lyr[:index] = nil unless lyr[:index] < lc.layers.size - log(ERR, "Insulation index for '#{id}'? (#{mth})") unless lyr[:index] + log(WRN, "Insulation index for '#{id}'? (#{mth})") unless lyr[:index] next unless lyr[:index] # Ensure lc is exclusively linked to deratable surfaces of right type. @@ -302,10 +297,10 @@ def uprate(model = nil, s = {}, argh = {}) next unless surface.key?(:construction) next unless surface[:construction].is_a?(cl3) next unless surface[:construction] == lc + next unless surface[:deratable] ok = true - ok = false unless surface[:type ] == typ - ok = false unless surface[:deratable] + ok = false unless surface[:type] == typ ok = false unless coll.key?(id) ok = false unless coll[id][:s].key?(nom) @@ -346,13 +341,18 @@ def uprate(model = nil, s = {}, argh = {}) if sss.isConstructionDefaulted set = defaultConstructionSet(sss) # building? story? - constructions = set.defaultExteriorSurfaceConstructions - unless constructions.empty? - constructions = constructions.get - constructions.setWallConstruction(lc) if typ == :wall - constructions.setFloorConstruction(lc) if typ == :floor - constructions.setRoofCeilingConstruction(lc) if typ == :ceiling + if set.nil? + sss.setConstruction(lc) + else + constructions = set.defaultExteriorSurfaceConstructions + + unless constructions.empty? + constructions = constructions.get + constructions.setWallConstruction(lc) if typ == :wall + constructions.setFloorConstruction(lc) if typ == :floor + constructions.setRoofCeilingConstruction(lc) if typ == :ceiling + end end else sss.setConstruction(lc) @@ -385,7 +385,7 @@ def uprate(model = nil, s = {}, argh = {}) res = uo(model, lc, id, hloss, film, g[:ut]) unless res[:uo] && res[:m] - log(ERR, "Unable to uprate '#{id}' (#{mth})") + log(WRN, "Unable to uprate '#{id}' (#{mth})") next end @@ -407,7 +407,7 @@ def uprate(model = nil, s = {}, argh = {}) argh[:roof_uo ] = res[:uo] if typ == :ceiling argh[:floor_uo] = res[:uo] if typ == :floor else - log(ERR, "Nilled construction to uprate - (#{mth})") + log(WRN, "Nilled construction to uprate - (#{mth})") return false end end @@ -589,7 +589,7 @@ def ua_summary(date = Time.now, argh = {}) empty = shorts[:has].empty? && shorts[:val].empty? has = shorts[:has] unless empty val = shorts[:val] unless empty - log(ERR, "Invalid UA' reference set (#{mth})") if empty + log(WRN, "Invalid UA' reference set (#{mth})") if empty unless empty ua[:model] += " : Design vs '#{ref}'" @@ -1000,7 +1000,7 @@ def ua_md(ua = {}, lang = :en) model = "* modèle : #{ua[:file]}" if ua.key?(:file) && lang == :fr model += " (v#{ua[:version]})" if ua.key?(:version) report << model unless model.empty? - report << "* TBD : v3.4.5" + report << "* TBD : v3.5.0" report << "* date : #{ua[:date]}" if lang == :en diff --git a/lib/measures/tbd/resources/utils.rb b/lib/measures/tbd/resources/utils.rb index 35cb90c..77d8014 100644 --- a/lib/measures/tbd/resources/utils.rb +++ b/lib/measures/tbd/resources/utils.rb @@ -31,20 +31,26 @@ require "openstudio" module OSut - # DEBUG for devs; WARN/ERROR for users (bad OS input), see OSlg extend OSlg - TOL = 0.01 # default distance tolerance (m) - TOL2 = TOL * TOL # default area tolerance (m2) - DBG = OSlg::DEBUG.dup # see github.com/rd2/oslg - INF = OSlg::INFO.dup # see github.com/rd2/oslg - WRN = OSlg::WARN.dup # see github.com/rd2/oslg - ERR = OSlg::ERROR.dup # see github.com/rd2/oslg - FTL = OSlg::FATAL.dup # see github.com/rd2/oslg - NS = "nameString" # OpenStudio object identifier method - - HEAD = 2.032 # standard 80" door - SILL = 0.762 # standard 30" window sill + DBG = OSlg::DEBUG.dup # see github.com/rd2/oslg + INF = OSlg::INFO.dup # see github.com/rd2/oslg + WRN = OSlg::WARN.dup # see github.com/rd2/oslg + ERR = OSlg::ERROR.dup # see github.com/rd2/oslg + FTL = OSlg::FATAL.dup # see github.com/rd2/oslg + NS = "nameString" # OpenStudio object identifier method + TOL = 0.01 # default distance tolerance (m) + TOL2 = TOL * TOL # default area tolerance (m2) + HEAD = 2.032 # standard 80" door + SILL = 0.762 # standard 30" window sill + DMIN = 0.010 # min. insulating material thickness + DMAX = 1.000 # max. insulating material thickness + KMIN = 0.010 # min. insulating material thermal conductivity + KMAX = 2.000 # max. insulating material thermal conductivity + UMAX = KMAX / DMIN # material USi upper limit, 200.000 + UMIN = KMIN / DMAX # material USi lower limit, 0.010 + RMIN = 1.0 / UMAX # material RSi lower limit, 0.005 (or R-IP 0.03) + RMAX = 1.0 / UMIN # material RSi upper limit, 100.000 (or R-IP 567.80) # General surface orientations (see facets method) SIDZ = [:bottom, # e.g. ground-facing, exposed floors @@ -191,6 +197,388 @@ module OSut @@mats[:door ][:rho] = 600.000 @@mats[:door ][:cp ] = 1000.000 + ## + # 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) + return mismatch(lc.nameString, lc, cl, mth, DBG, 0.0) unless lc.is_a?(cl) + + unless standardOpaqueLayers?(lc) + log(ERR, "#{lc.nameString} holds non-StandardOpaqueMaterial(s) (#{mth})") + return 0.0 + end + + 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. + # + # 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) + return mismatch(lc.nameString, 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.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) + return mismatch(lc.nameString, lc, cl, 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 < RMIN || 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 < DMIN || k > KMAX || 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 a material is both uniquely reserved to a single layered + # construction in a model, and referenced only once in the construction. + # Limited to 'standard' or 'massless' materials. + # + # @param m [OpenStudio::Model::OpaqueMaterial] a material + # + # @return [Boolean] whether material is unique + # @return [false] if missing) + def uniqueMaterial?(m = nil) + mth = "OSut::#{__callee__}" + cl1 = OpenStudio::Model::OpaqueMaterial + return invalid("mat", mth, 1, DBG, false) unless m.respond_to?(NS) + return mismatch(m.nameString, m, cl1, mth, DBG, false) unless m.is_a?(cl1) + + num = 0 + lcs = m.model.getLayeredConstructions + + unless m.to_MasslessOpaqueMaterial.empty? + m = m.to_MasslessOpaqueMaterial.get + + lcs.each { |lc| num += lc.getLayerIndices(m).size } + + return true if num == 1 + end + + unless m.to_StandardOpaqueMaterial.empty? + m = m.to_StandardOpaqueMaterial.get + + lcs.each { |lc| num += lc.getLayerIndices(m).size } + + return true if num == 1 + end + + false + end + + ## + # Sets a layered construction material as unique. Solution similar to + # OpenStudio::Model::LayeredConstruction's 'ensureUniqueLayers', yet limited + # here to a single indexed OpenStudio material, typically the principal + # insulating material. Returns true if the indexed material is already unique. + # Limited to 'standard' or 'massless' materials. + # + # @param lc [OpenStudio::Model::LayeredConstruction] a construction + # @param index [Integer] the construction layer index of the material + # + # @return [Boolean] if assigned as unique + # @return [false] if invalid inputs + def assignUniqueMaterial(lc = nil, index = nil) + mth = "OSut::#{__callee__}" + cl1 = OpenStudio::Model::LayeredConstruction + cl2 = Integer + return invalid("lc", mth, 1, DBG, false) unless lc.respond_to?(NS) + return mismatch(lc.nameString, lc, cl1, mth, DBG, false) unless lc.is_a?(cl1) + return mismatch("index", index, cl2, mth, DBG, false) unless index.is_a?(cl2) + return invalid("index", mth, 0, DBG, false) unless index.between?(0, lc.numLayers - 1) + + m = lc.getLayer(index) + + unless m.to_MasslessOpaqueMaterial.empty? + m = m.to_MasslessOpaqueMaterial.get + return true if uniqueMaterial?(m) + + mat = m.clone(m.model).to_MasslessOpaqueMaterial.get + return lc.setLayer(index, mat) + end + + unless m.to_StandardOpaqueMaterial.empty? + m = m.to_StandardOpaqueMaterial.get + return true if uniqueMaterial?(m) + + mat = m.clone(m.model).to_StandardOpaqueMaterial.get + return lc.setLayer(index, mat) + end + + false + end + + ## + # Resets a construction's Uo factor by adjusting its insulating layer + # thermal conductivity, then if needed its thickness (or its RSi value if + # massless). Unless material uniquness is requested, a matching material is + # recovered instead of instantiating a new one. The latter is renamed + # according to its adjusted conductivity/thickness (or RSi value). + # + # @param lc [OpenStudio::Model::LayeredConstruction] a construction + # @param film [Float] construction air film resistance + # @param index [Integer] the insulating layer's array index + # @param uo [Float] desired Uo factor (with air film resistance) + # @param uniq [Boolean] whether to enforce material uniqueness + # + # @return [Float] new layer RSi [RMIN, RMAX] + # @return [0.0] if invalid input + def resetUo(lc = nil, film = nil, index = nil, uo = nil, uniq = false) + mth = "OSut::#{__callee__}" + r = 0.0 # thermal resistance of new material + cl1 = OpenStudio::Model::LayeredConstruction + cl2 = Numeric + cl3 = Integer + return invalid("lc", mth, 1, DBG, r) unless lc.respond_to?(NS) + return mismatch(lc.nameString, lc, cl1, mth, DBG, r) unless lc.is_a?(cl1) + return mismatch("film", film, cl2, mth, DBG, r) unless film.is_a?(cl2) + return negative("film", mth, DBG, r) if film.negative? + return mismatch("index", index, cl3, mth, DBG, r) unless index.is_a?(cl3) + return invalid("index", mth, 3, DBG, r) unless index.between?(0, lc.numLayers - 1) + return mismatch("uo", uo, cl2, mth, DBG, r) unless uo.is_a?(cl2) + + unless uo.between?(UMIN, UMAX) + uo = clamp(UMIN, UMAX) + log(WRN, "Resetting Uo (#{lc.nameString}) to #{uo.round(3)} (#{mth})") + end + + uniq = false unless [true, false].include?(uniq) + r0 = rsi(lc, film) # current construction RSi value + ro = 1 / uo # desired construction RSi value + dR = ro - r0 # desired increase in construction RSi + m = lc.getLayer(index) + + unless m.to_MasslessOpaqueMaterial.empty? + m = m.to_MasslessOpaqueMaterial.get + r = m.thermalResistance + return r if dR.abs.round(2) == 0.00 + + r = (r + dR).clamp(RMIN, RMAX) + id = "OSut:RSi#{r.round(2)}" + mt = lc.model.getMasslessOpaqueMaterialByName(id) + + # Existing material? + unless mt.empty? + mt = mt.get + + if r.round(2) == mt.thermalResistance.round(2) && uniq == false + lc.setLayer(index, mt) + return r + end + end + + mt = m.clone(m.model).to_MasslessOpaqueMaterial.get + mt.setName(id) + + unless mt.setThermalResistance(r) + return invalid("Failed #{id}: RSi#{de_r.round(2)}", mth) + end + + lc.setLayer(index, mt) + + return r + end + + unless m.to_StandardOpaqueMaterial.empty? + m = m.to_StandardOpaqueMaterial.get + r = m.thickness / m.conductivity + return r if dR.abs.round(2) == 0.00 + + k = (m.thickness / (r + dR)).clamp(KMIN, KMAX) + d = (k * (r + dR)).clamp(DMIN, DMAX) + r = d / k + id = "OSUT:K#{format('%4.3f', k)}:#{format('%03d', d*1000)[-3..-1]}" + mt = lc.model.getStandardOpaqueMaterialByName(id) + + # Existing material? + unless mt.empty? + mt = mt.get + rt = mt.thickness / mt.conductivity + + if r.round(2) == rt.round(2) && uniq == false + lc.setLayer(index, mt) + return r + end + end + + mt = m.clone(m.model).to_StandardOpaqueMaterial.get + mt.setName(id) + + unless mt.setThermalConductivity(k) + return invalid("Failed #{id}: K#{k.round(3)}", mth) + end + + unless mt.setThickness(d) + return invalid("Failed #{id}: #{(d*1000).to_i}mm", mth) + end + + lc.setLayer(index, mt) + + return r + end + + 0 + end + ## # Generates an OpenStudio multilayered construction, + materials if needed. # @@ -214,20 +602,27 @@ def genConstruction(model = nil, specs = {}) specs[:id] = "" unless specs.key?(:id) id = trim(specs[:id]) - id = "OSut|CON|#{specs[:type]}" if id.empty? + id = "OSut:CON:#{specs[:type]}" if id.empty? - specs[:type] = :wall unless specs.key?(:type) - chk = @@uo.keys.include?(specs[:type]) - return invalid("surface type", mth, 2, ERR) unless chk + if specs.key?(:type) + unless @@uo.keys.include?(specs[:type]) + return invalid("surface type", mth, 2, ERR) + end + else + specs[:type] = :wall + end specs[:uo] = @@uo[ specs[:type] ] unless specs.key?(:uo) # can be nil u = specs[:uo] - if u - 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 zero("#{id} Uo", mth, ERR) if u.round(2) == 0.00 - return negative("#{id} Uo", mth, ERR) if u < 0 + unless u.nil? + return mismatch("#{id} Uo", u, Numeric, mth) unless u.is_a?(Numeric) + + unless u.between?(UMIN, 5.678) + uO = u + u = uO.clamp(UMIN, 5.678) + log(ERR, "Resetting Uo #{uO.round(3)} to #{u.round(3)} (#{mth})") + end end # Optional specs. Log/reset if invalid. @@ -256,14 +651,14 @@ def genConstruction(model = nil, specs = {}) d = 0.015 a[:compo][:mat] = @@mats[mt] a[:compo][:d ] = d - a[:compo][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}" + a[:compo][:id ] = "OSut:#{mt}:#{format('%03d', d*1000)[-3..-1]}" when :partition unless specs[:clad] == :none 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]}" + a[:clad][:id ] = "OSut:#{mt}:#{format('%03d', d*1000)[-3..-1]}" end d = 0.015 @@ -275,14 +670,14 @@ def genConstruction(model = nil, specs = {}) mt = :mineral if u a[:compo][:mat] = @@mats[mt] a[:compo][:d ] = d - a[:compo][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}" + a[:compo][:id ] = "OSut:#{mt}:#{format('%03d', d*1000)[-3..-1]}" unless specs[:finish] == :none d = 0.015 mt = :drywall a[:finish][:mat] = @@mats[mt] a[:finish][:d ] = d - a[:finish][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}" + a[:finish][:id ] = "OSut:#{mt}:#{format('%03d', d*1000)[-3..-1]}" end when :wall unless specs[:clad] == :none @@ -293,7 +688,7 @@ def genConstruction(model = nil, specs = {}) 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]}" + a[:clad][:id ] = "OSut:#{mt}:#{format('%03d', d*1000)[-3..-1]}" end mt = :drywall @@ -303,7 +698,7 @@ def genConstruction(model = nil, specs = {}) 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]}" + a[:sheath][:id ] = "OSut:#{mt}:#{format('%03d', d*1000)[-3..-1]}" mt = :mineral mt = :cellulose if specs[:frame] == :medium @@ -315,7 +710,7 @@ def genConstruction(model = nil, specs = {}) a[:compo][:mat] = @@mats[mt] a[:compo][:d ] = d - a[:compo][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}" + a[:compo][:id ] = "OSut:#{mt}:#{format('%03d', d*1000)[-3..-1]}" unless specs[:finish] == :none mt = :concrete @@ -325,7 +720,7 @@ def genConstruction(model = nil, specs = {}) 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]}" + a[:finish][:id ] = "OSut:#{mt}:#{format('%03d', d*1000)[-3..-1]}" end when :roof unless specs[:clad] == :none @@ -336,7 +731,7 @@ def genConstruction(model = nil, specs = {}) 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]}" + a[:clad][:id ] = "OSut:#{mt}:#{format('%03d', d*1000)[-3..-1]}" end mt = :mineral @@ -347,7 +742,7 @@ def genConstruction(model = nil, specs = {}) d = 0.015 unless u a[:compo][:mat] = @@mats[mt] a[:compo][:d ] = d - a[:compo][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}" + a[:compo][:id ] = "OSut:#{mt}:#{format('%03d', d*1000)[-3..-1]}" unless specs[:finish] == :none mt = :concrete @@ -357,7 +752,7 @@ def genConstruction(model = nil, specs = {}) 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]}" + a[:finish][:id ] = "OSut:#{mt}:#{format('%03d', d*1000)[-3..-1]}" end when :floor unless specs[:clad] == :none @@ -365,7 +760,7 @@ def genConstruction(model = nil, specs = {}) d = 0.015 a[:clad][:mat] = @@mats[mt] a[:clad][:d ] = d - a[:clad][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}" + a[:clad][:id ] = "OSut:#{mt}:#{format('%03d', d*1000)[-3..-1]}" end mt = :mineral @@ -376,7 +771,7 @@ def genConstruction(model = nil, specs = {}) d = 0.015 unless u a[:compo][:mat] = @@mats[mt] a[:compo][:d ] = d - a[:compo][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}" + a[:compo][:id ] = "OSut:#{mt}:#{format('%03d', d*1000)[-3..-1]}" unless specs[:finish] == :none mt = :concrete @@ -386,21 +781,21 @@ def genConstruction(model = nil, specs = {}) 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]}" + a[:finish][:id ] = "OSut:#{mt}:#{format('%03d', d*1000)[-3..-1]}" end when :slab 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]}" + 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]}" + a[:sheath][:id ] = "OSut:#{mt}:#{format('%03d', d*1000)[-3..-1]}" end mt = :concrete @@ -408,14 +803,14 @@ def genConstruction(model = nil, specs = {}) 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]}" + a[:compo][:id ] = "OSut:#{mt}:#{format('%03d', d*1000)[-3..-1]}" unless specs[:finish] == :none mt = :material d = 0.015 a[:finish][:mat] = @@mats[mt] a[:finish][:d ] = d - a[:finish][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}" + a[:finish][:id ] = "OSut:#{mt}:#{format('%03d', d*1000)[-3..-1]}" end when :basement unless specs[:clad] == :none @@ -425,38 +820,38 @@ def genConstruction(model = nil, specs = {}) 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]}" + 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]}" + 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]}" + 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]}" + 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]}" + 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]}" + a[:finish][:id ] = "OSut:#{mt}:#{format('%03d', d*1000)[-3..-1]}" end end when :door @@ -465,27 +860,25 @@ def genConstruction(model = nil, specs = {}) a[:compo ][:mat ] = @@mats[mt] a[:compo ][:d ] = d - a[:compo ][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}" + a[:compo ][:id ] = "OSut:#{mt}:#{format('%03d', d*1000)[-3..-1]}" when :window a[:glazing][:u ] = u ? u : @@uo[:window] 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)}" + 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 a[:glazing][:u ] = u ? u : @@uo[:skylight] 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)}" + 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. - unglazed = a[:glazing].empty? ? true : false - - if unglazed + if a[:glazing].empty? layers = OpenStudio::Model::OpaqueMaterialVector.new # Loop through each layer spec, and generate construction. @@ -528,14 +921,15 @@ def genConstruction(model = nil, specs = {}) layers << lyr end - c = OpenStudio::Model::Construction.new(layers) + c = OpenStudio::Model::Construction.new(layers) c.setName(id) - # Adjust insulating layer thickness or conductivity to match requested Uo. - if u and unglazed + # Adjust insulating layer conductivity (maybe thickness) to match Uo. + if u and a[:glazing].empty? ro = 1 / u - film - if ro > 0 + + if ro > RMIN if specs[:type] == :door # 1x layer, adjust conductivity layer = c.getLayer(0).to_StandardOpaqueMaterial return invalid("#{id} standard material?", mth, 0) if layer.empty? @@ -543,34 +937,33 @@ def genConstruction(model = nil, specs = {}) layer = layer.get k = layer.thickness / ro layer.setConductivity(k) - else # multiple layers, adjust insulating layer thickness + else # multiple layers, adjust layer conductivity, then 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? + return invalid("#{id} construction", mth, 0) if lyr[:r ].to_i.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] + k = (layer.thickness / (ro - rsi(c) + lyr[:r])).clamp(KMIN, KMAX) + d = (k * (ro - rsi(c) + lyr[:r])).clamp(DMIN, DMAX) + + nom = "OSut:" + nom += layer.nameString.gsub(/[^a-z]/i, "").gsub("OSut", "") + nom += ":K#{format('%4.3f', k)}:#{format('%03d', d*1000)[-3..-1]}" lyr = model.getStandardOpaqueMaterialByName(nom) if lyr.empty? layer.setName(nom) + layer.setConductivity(k) layer.setThickness(d) else - omat = lyr.get - c.setLayer(index, omat) + c.setLayer(index, lyr.get) end end end @@ -619,20 +1012,20 @@ def genShade(subs = OpenStudio::Model::SubSurfaceVector.new) end # Shading schedule. - id = "OSut|SHADE|Ruleset" + 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") + sch.defaultDaySchedule.setName("OSut:Shade:Ruleset:Default") else sch = sch.get end # Summer cooling rule. - id = "OSut|SHADE|ScheduleRule" + id = "OSut:SHADE:ScheduleRule" rule = mdl.getScheduleRuleByName(id) if rule.empty? @@ -646,14 +1039,14 @@ def genShade(subs = OpenStudio::Model::SubSurfaceVector.new) rule.setStartDate(start) rule.setEndDate(finish) rule.setApplyAllDays(true) - rule.daySchedule.setName("OSut|Shade|Rule|Default") + 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" + id = "OSut:Shade" shd = mdl.getShadeByName(id) if shd.empty? @@ -664,7 +1057,7 @@ def genShade(subs = OpenStudio::Model::SubSurfaceVector.new) end # Shading control (unique to each call). - id = "OSut|ShadingControl" + id = "OSut:ShadingControl" ctl = OpenStudio::Model::ShadingControl.new(shd) ctl.setName(id) ctl.setSchedule(sch) @@ -700,7 +1093,7 @@ def genMass(sps = OpenStudio::Model::SpaceVector.new, ratio = 2.0) # A single material. mdl = sps.first.model - id = "OSut|MASS|Material" + id = "OSut:MASS:Material" mat = mdl.getOpaqueMaterialByName(id) if mat.empty? @@ -719,7 +1112,7 @@ def genMass(sps = OpenStudio::Model::SpaceVector.new, ratio = 2.0) end # A single, 1x layered construction. - id = "OSut|MASS|Construction" + id = "OSut:MASS:Construction" con = mdl.getConstructionByName(id) if con.empty? @@ -732,7 +1125,7 @@ def genMass(sps = OpenStudio::Model::SpaceVector.new, ratio = 2.0) con = con.get end - id = "OSut|InternalMassDefinition|" + (format "%.2f", ratio) + id = "OSut:InternalMassDefinition:" + (format "%.2f", ratio) df = mdl.getInternalMassDefinitionByName(id) if df.empty? @@ -746,7 +1139,7 @@ def genMass(sps = OpenStudio::Model::SpaceVector.new, ratio = 2.0) sps.each do |sp| mass = OpenStudio::Model::InternalMass.new(df) - mass.setName("OSut|InternalMass|#{sp.nameString}") + mass.setName("OSut:InternalMass:#{sp.nameString}") mass.setSpace(sp) end @@ -754,13 +1147,13 @@ def genMass(sps = OpenStudio::Model::SpaceVector.new, ratio = 2.0) end ## - # Validates if a default construction set holds a base construction. + # Validates if a default construction set holds an opaque base construction. # # @param set [OpenStudio::Model::DefaultConstructionSet] a default set - # @param bse [OpenStudio::Model::ConstructionBase] a construction base + # @param bse [OpenStudio::Model::ConstructionBase] an opaque construction base # @param gr [Bool] if ground-facing surface # @param ex [Bool] if exterior-facing surface - # @param tp [#to_s] a surface type + # @param tp [#to_sym] surface type: "floor", "wall" or "roofceiling" # # @return [Bool] whether default set holds construction # @return [false] if invalid input (see logs) @@ -779,7 +1172,7 @@ def holdsConstruction?(set = nil, bse = nil, gr = false, ex = false, tp = "") ck2 = bse.is_a?(cl2) ck3 = [true, false].include?(gr) ck4 = [true, false].include?(ex) - ck5 = tp.respond_to?(:to_s) + ck5 = tp.respond_to?(:to_sym) 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 @@ -859,6 +1252,9 @@ def defaultConstructionSet(s = nil) type = s.surfaceType ground = false exterior = false + adjacent = s.adjacentSurface.empty? ? nil : s.adjacentSurface.get + aspace = adjacent.nil? || adjacent.space.empty? ? nil : adjacent.space.get + typ = adjacent.nil? ? nil : adjacent.surfaceType if s.isGroundSurface ground = true @@ -866,7 +1262,14 @@ def defaultConstructionSet(s = nil) exterior = true end - unless space.defaultConstructionSet.empty? + if space.defaultConstructionSet.empty? + unless aspace.nil? + unless aspace.defaultConstructionSet.empty? + set = aspace.defaultConstructionSet.get + return set if holdsConstruction?(set, base, ground, exterior, typ) + end + end + else set = space.defaultConstructionSet.get return set if holdsConstruction?(set, base, ground, exterior, type) end @@ -880,6 +1283,17 @@ def defaultConstructionSet(s = nil) end end + unless aspace.nil? || aspace.spaceType.empty? + unless aspace.spaceType.empty? + spacetype = aspace.spaceType.get + + unless spacetype.defaultConstructionSet.empty? + set = spacetype.defaultConstructionSet.get + return set if holdsConstruction?(set, base, ground, exterior, typ) + end + end + end + unless space.buildingStory.empty? story = space.buildingStory.get @@ -889,6 +1303,15 @@ def defaultConstructionSet(s = nil) end end + unless aspace.nil? || aspace.buildingStory.empty? + story = aspace.buildingStory.get + + unless spacetype.defaultConstructionSet.empty? + set = spacetype.defaultConstructionSet.get + return set if holdsConstruction?(set, base, ground, exterior, typ) + end + end + building = mdl.getBuilding unless building.defaultConstructionSet.empty? @@ -899,203 +1322,6 @@ def defaultConstructionSet(s = nil) 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, cl, 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 @@ -2216,7 +2442,7 @@ def availabilitySchedule(model = nil, avl = "") 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 invalid("availability", avl, 2, mth) unless avl.respond_to?(:to_sym) # Either fetch availability ScheduleTypeLimits object, or create one. model.getScheduleTypeLimitss.each do |l| @@ -4648,7 +4874,7 @@ def spaceWidth(space = nil) # # @param s [Set] a (larger) parent set of points # @param [Array] set a collection of (smaller) sequenced points - # @option [Symbol] tag sequence of subset vertices to target + # @option [#to_sym] tag sequence of subset vertices to target # # @return [Integer] number of successfully anchored subsets (see logs) def genAnchors(s = nil, set = [], tag = :box) @@ -4656,18 +4882,20 @@ def genAnchors(s = nil, set = [], tag = :box) n = 0 id = s.respond_to?(:nameString) ? "#{s.nameString}: " : "" pts = poly(s) - return invalid("#{id} polygon", mth, 1, DBG, n) if pts.empty? - return mismatch("set", set, Array, mth, DBG, n) unless set.respond_to?(:to_a) + return invalid("#{id} polygon", mth, 1, DBG, n) if pts.empty? + return mismatch("set", set, Array, mth, DBG, n) unless set.respond_to?(:to_a) + return mismatch("tag", tag, Symbol, mth, DBG, n) unless tag.respond_to?(:to_sym) origin = OpenStudio::Point3d.new(0,0,0) zenith = OpenStudio::Point3d.new(0,0,1) ray = zenith - origin set = set.to_a + tag = tag.to_sym # Validate individual subsets. Purge surface-specific leader line anchors. set.each_with_index do |st, i| str1 = id + "subset ##{i+1}" - str2 = str1 + " #{tag.to_s}" + str2 = str1 + " #{trim(tag)}" return mismatch(str1, st, Hash, mth, DBG, n) unless st.respond_to?(:key?) return hashkey( str1, st, tag, mth, DBG, n) unless st.key?(tag) return empty("#{str2} vertices", mth, DBG, n) if st[tag].empty? @@ -4810,7 +5038,7 @@ def genAnchors(s = nil, set = [], tag = :box) # @param s [Set] a larger (parent) set of points # @param [Array] set a collection of (smaller) sequenced vertices # @option set [Hash] :ld a polygon-specific leader line anchors - # @option [Symbol] tag sequence of set vertices to target + # @option [#to_sym] tag sequence of set vertices to target # # @return [OpenStudio::Point3dVector] extended vertices (see logs if empty) def genExtendedVertices(s = nil, set = [], tag = :vtx) @@ -4822,14 +5050,16 @@ def genExtendedVertices(s = nil, set = [], tag = :vtx) a = OpenStudio::Point3dVector.new v = [] return a if pts.empty? - return mismatch("set", set, Array, mth, DBG, a) unless set.respond_to?(:to_a) + return mismatch("set", set, Array, mth, DBG, a) unless set.respond_to?(:to_a) + return mismatch("tag", tag, Symbol, mth, DBG, n) unless tag.respond_to?(:to_sym) set = set.to_a + tag = tag.to_sym # Validate individual sets. set.each_with_index do |st, i| str1 = id + "subset ##{i+1}" - str2 = str1 + " #{tag.to_s}" + str2 = str1 + " #{trim(tag)}" return mismatch(str1, st, Hash, mth, DBG, a) unless st.respond_to?(:key?) next if st.key?(:void) && st[:void] @@ -5121,8 +5351,8 @@ def genInserts(s = nil, set = []) # surface type filters if 'type' argument == "all". # # @param spaces [Set] target spaces - # @param boundary [#to_s] OpenStudio outside boundary condition - # @param type [#to_s] OpenStudio surface (or subsurface) type + # @param boundary [#to_sym] OpenStudio outside boundary condition + # @param type [#to_sym] OpenStudio surface (or subsurface) type # @param sides [Set] direction keys, e.g. :north (see OSut::SIDZ) # # @return [Array] surfaces (may be empty, no logs) @@ -5131,7 +5361,7 @@ def facets(spaces = [], boundary = "all", type = "all", sides = []) spaces = spaces.respond_to?(:to_a) ? spaces.to_a : [] return [] if spaces.empty? - sides = sides.respond_to?(:to_sym) ? [sides] : sides + sides = sides.respond_to?(:to_sym) ? [trim(sides).to_sym] : sides sides = sides.respond_to?(:to_a) ? sides.to_a : [] faces = [] @@ -5405,7 +5635,7 @@ def daylit?(space = nil, sidelit = true, toplit = true, baselit = true) # @param s [OpenStudio::Model::Surface] a model surface # @param [Array] 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_sym] :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 @@ -5534,12 +5764,13 @@ def addSubs(s = nil, subs = [], clear = false, bound = false, realign = false, b 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[:frame ] = nil unless sub.key?(:frame ) - sub[:assembly ] = nil unless sub.key?(:assembly ) - sub[:count ] = 1 unless sub.key?(:count ) + 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[:id ] = "" unless sub.key?(:id) + sub[:type ] = type unless sub.key?(:type) + sub[:type ] = type unless sub[:type].respond_to?(:to_sym) sub[:type ] = trim(sub[:type]) sub[:id ] = trim(sub[:id]) sub[:type ] = type if sub[:type].empty? @@ -6368,7 +6599,7 @@ def toToplit(spaces = [], opts = {}) # @option opts [Bool] :sloped (true) whether to consider sloped roof surfaces # @option opts [Bool] :plenum (true) whether to consider plenum wells # @option opts [Bool] :attic (true) whether to consider attic wells - # @option opts [Array<#to_s>] :patterns requested skylight allocation (3x) + # @option opts [Array<#to_sym>] :patterns requested skylight allocation (3x) # @example (a) consider 2D array of individual skylights, e.g. n(1.22m x 1.22m) # opts[:patterns] = ["array"] # @example (b) consider 'a', then array of 1x(size) x n(size) skylight strips @@ -6685,7 +6916,7 @@ def addSkyLights(spaces = [], opts = {}) if opts.key?(:patterns) if opts[:patterns].is_a?(Array) opts[:patterns].each_with_index do |pattern, i| - pattern = trim(pattern).downcase + pattern = pattern.respond_to?(:to_sym) ? trim(pattern).downcase : "" if pattern.empty? invalid("pattern #{i+1}", mth, 0, ERR) diff --git a/lib/tbd.rb b/lib/tbd.rb index 06054de..760e676 100644 --- a/lib/tbd.rb +++ b/lib/tbd.rb @@ -58,16 +58,22 @@ end module TBD - extend OSut # OpenStudio utilities - - TOL = OSut::TOL.dup # default distance tolerance (m) - TOL2 = OSut::TOL2.dup # default area tolerance (m2) - DBG = OSut::DEBUG.dup # github.com/rd2/oslg - INF = OSut::INFO.dup # github.com/rd2/oslg - WRN = OSut::WARN.dup # github.com/rd2/oslg - ERR = OSut::ERR.dup # github.com/rd2/oslg - FTL = OSut::FATAL.dup # github.com/rd2/oslg - NS = OSut::NS.dup # OpenStudio IdfObject nameString method - + extend OSut # OpenStudio utilities (github.com/rd2/oslg) + DBG = OSut::DEBUG.dup + INF = OSut::INFO.dup + WRN = OSut::WARN.dup + ERR = OSut::ERR.dup + FTL = OSut::FATAL.dup + NS = OSut::NS.dup + TOL = OSut::TOL.dup + TOL2 = OSut::TOL2.dup + DMIN = OSut::DMIN.dup + DMAX = OSut::DMAX.dup + KMIN = OSut::KMIN.dup + KMAX = OSut::KMAX.dup + UMAX = OSut::UMAX.dup + UMIN = OSut::UMIN.dup + RMIN = OSut::RMIN.dup + RMAX = OSut::RMAX.dup extend TBD end diff --git a/lib/tbd/geo.rb b/lib/tbd/geo.rb index 7dd2a6f..34e8f07 100644 --- a/lib/tbd/geo.rb +++ b/lib/tbd/geo.rb @@ -310,23 +310,24 @@ def properties(surface = nil, argh = {}) end unless surface.construction.empty? - construction = surface.construction.get.to_LayeredConstruction + lc = surface.construction.get.to_LayeredConstruction - unless construction.empty? - construction = construction.get - lyr = insulatingLayer(construction) - lyr[:index] = nil unless lyr[:index].is_a?(Numeric) - lyr[:index] = nil unless lyr[:index] >= 0 - lyr[:index] = nil unless lyr[:index] < construction.layers.size + unless lc.empty? + lc = lc.get + lyr = insulatingLayer(lc) - if lyr[:index] - surf[:construction] = construction + if lyr[:index].is_a?(Integer) && lyr[:index].between?(0, lc.numLayers - 1) + surf[:construction] = lc # index: ... of layer/material (to derate) within construction # ltype: either :massless (RSi) or :standard (k + d) # r : initial RSi value of the indexed layer to derate surf[:index] = lyr[:index] surf[:ltype] = lyr[:type ] surf[:r ] = lyr[:r ] + else + surf[:index] = nil + surf[:ltype] = nil + surf[:r ] = 0.0 end end end diff --git a/lib/tbd/psi.rb b/lib/tbd/psi.rb index 8075770..d80874c 100644 --- a/lib/tbd/psi.rb +++ b/lib/tbd/psi.rb @@ -1355,103 +1355,103 @@ def inputs(s = {}, e = {}, argh = {}) ## # Thermally derates insulating material within construction. # - # @param id [#to_s] surface identifier + # @param id [#to_sym] surface identifier # @param [Hash] s TBD surface parameters - # @option s [#to_f] :heatloss heat loss from major thermal bridging, in W/K - # @option s [#to_f] :net surface net area, in m2 + # @option s [Numeric] :heatloss heat loss from major thermal bridging, in W/K + # @option s [Numeric] :net surface net area, in m2 # @option s [:massless, :standard] :ltype indexed layer type - # @option s [#to_i] :index deratable construction layer index - # @option s [#to_f] :r deratable layer Rsi-factor, in m2•K/W + # @option s [Integer] :index deratable construction layer index + # @option s [Numeric] :r deratable layer Rsi-factor, in m2•K/W # @param lc [OpenStudio::Model::LayeredConstruction] a layered construction # - # @return [OpenStudio::Model::Material] derated (cloned) material + # @return [OpenStudio::Model::OpaqueMaterial] derated (cloned) material # @return [nil] if invalid input (see logs) def derate(id = "", s = {}, lc = nil) mth = "TBD::#{__callee__}" m = nil - id = trim(id) kys = [:heatloss, :net, :ltype, :index, :r] - ck1 = s.is_a?(Hash) - ck2 = lc.is_a?(OpenStudio::Model::LayeredConstruction) - return mismatch("id" , id, cl6, mth) if id.empty? - return mismatch("#{id} surface" , s , cl1, mth) unless ck1 - return mismatch("#{id} construction", lc, cl2, mth) unless ck2 + cl = OpenStudio::Model::LayeredConstruction + return mismatch("lc", lc, cl, mth) unless lc.respond_to?(NS) + return mismatch("id", id, String, mth) unless id.respond_to?(:to_sym) + + id = trim(id) + nom = lc.nameString + return invalid("id", mth, 1) if id.empty? + return mismatch(nom, lc, cl, mth) unless lc.is_a?(cl) + return mismatch("#{nom} surface", s, Hash, mth) unless s.is_a?(Hash) + + if nom.downcase.include?(" tbd") + log(WRN, "Won't derate '#{nom}': tagged as derated (#{mth})") + return m + end kys.each do |k| tag = "#{id} #{k}" - return hashkey(tag, s, k, mth, ERR) unless s.key?(k) + return hashkey(tag, s, k, mth) unless s.key?(k) case k - when :heatloss - return mismatch(tag, s[k], Numeric, mth) unless s[k].respond_to?(:to_f) - return zero(tag, mth, WRN) if s[k].to_f.abs < 0.001 - when :net, :r - return mismatch(tag, s[k], Numeric, mth) unless s[k].respond_to?(:to_f) - return negative(tag, mth, 2, ERR) if s[k].to_f < 0 - return zero(tag, mth, WRN) if s[k].to_f.abs < 0.001 - when :index - return mismatch(tag, s[k], Numeric, mth) unless s[k].respond_to?(:to_i) - return negative(tag, mth, 2, ERR) if s[k].to_f < 0 - else # :ltype + when :ltype next if [:massless, :standard].include?(s[k]) - return invalid(tag, mth, 2, ERR) - end - end + return invalid(tag, mth, 2) + when :index + return mismatch(tag, s[k], Integer, mth) unless s[k].is_a?(Integer) + return invalid(tag, mth, 2) unless s[k].between?(0, lc.numLayers - 1) + else + return mismatch(tag, s[k], Numeric, mth) unless s[k].is_a?(Numeric) + next if k == :heatloss - if lc.nameString.downcase.include?(" tbd") - log(WRN, "Won't derate '#{id}': tagged as derated (#{mth})") - return m + return negative(tag, mth, 2) if s[k] < 0 + return zero(tag, mth) if s[k].abs < 0.001 + end end model = lc.model - ltype = s[:ltype ] - index = s[:index ].to_i - net = s[:net ].to_f - r = s[:r ].to_f - u = s[:heatloss].to_f / net + ltype = s[:ltype] + index = s[:index] + net = s[:net] + r = s[:r] + u = s[:heatloss] / net loss = 0 - de_u = 1 / r + u # derated U - de_r = 1 / de_u # derated R + de_u = 1 / r + u # derated insulating material U + de_r = 1 / de_u # derated insulating material R if ltype == :massless - m = lc.getLayer(index).to_MasslessOpaqueMaterial + m = lc.getLayer(index).to_MasslessOpaqueMaterial return invalid("#{id} massless layer?", mth, 0) if m.empty? - m = m.get - up = "" - up = "uprated " if m.nameString.downcase.include?(" uprated") - m = m.clone(model).to_MasslessOpaqueMaterial.get - m.setName("#{id} #{up}m tbd") - de_r = 0.001 unless de_r > 0.001 - loss = (de_u - 1 / de_r) * net unless de_r > 0.001 - m.setThermalResistance(de_r) + + m = m.get + up = m.nameString.downcase.include?(" uprated") ? "uprated " : "" + m = m.clone(model).to_MasslessOpaqueMaterial.get + m.setName("#{id} #{up}m tbd") + + de_r = RMIN unless de_r > RMIN + loss = (de_u - 1 / de_r) * net unless de_r > RMIN + + unless m.setThermalResistance(de_r) + return invalid("Can't derate #{id}: RSi#{de_r.round(2)}", mth) + end else - m = lc.getLayer(index).to_StandardOpaqueMaterial + m = lc.getLayer(index).to_StandardOpaqueMaterial return invalid("#{id} standard layer?", mth, 0) if m.empty? - m = m.get - up = "" - up = "uprated " if m.nameString.downcase.include?(" uprated") - m = m.clone(model).to_StandardOpaqueMaterial.get - m.setName("#{id} #{up}m tbd") - k = m.thermalConductivity - - if de_r > 0.001 - d = de_r * k - - unless d > 0.003 - d = 0.003 - k = d / de_r - k = 3 unless k < 3 - loss = (de_u - k / d) * net unless k < 3 - end - else # de_r < 0.001 m2•K/W - d = 0.001 * k - d = 0.003 unless d > 0.003 - k = d / 0.001 unless d > 0.003 - loss = (de_u - k / d) * net + + m = m.get + up = m.nameString.downcase.include?(" uprated") ? "uprated " : "" + m = m.clone(model).to_StandardOpaqueMaterial.get + m.setName("#{id} #{up}m tbd") + + d = m.thickness + k = (d / de_r).clamp(KMIN, KMAX) + d = (k * de_r).clamp(DMIN, DMAX) + + loss = (de_u - k / d) * net unless d / k > RMIN + + unless m.setThermalConductivity(k) + return invalid("Can't derate #{id}: K#{k.round(3)}", mth) end - m.setThickness(d) - m.setThermalConductivity(k) + unless m.setThickness(d) + return invalid("Can't derate #{id}: #{(d*1000).to_i}mm", mth) + end end if m && loss > TOL @@ -1941,14 +1941,38 @@ def process(model = nil, argh = {}) ids = windows.keys + doors.keys + skylights.keys end - unless ids.include?(i) - log(ERR, "Orphaned subsurface #{i} (mth)") + adj = nil + + unless ids.include?(i) # adjacent sub surface? + sb = model.getSubSurfaceByName(i) + + if sb.empty? + log(DBG, "Orphaned subsurface #{i} (#{mth})?") + else + sb = sb.get + adj = sb.adjacentSubSurface + + if adj.empty? + log(DBG, "Orphaned sub #{i} (#{mth})?") + end + end + next end - window = windows.key?(i) ? windows[i] : {} - door = doors.key?(i) ? doors[i] : {} - skylight = skylights.key?(i) ? skylights[i] : {} + if adj + window = windows.key?(i) ? windows[i] : {} + door = doors.key?(i) ? doors[i] : {} + skylight = skylights.key?(i) ? skylights[i] : {} + else + window = windows.key?(i) ? windows[i] : {} + door = doors.key?(i) ? doors[i] : {} + skylight = skylights.key?(i) ? skylights[i] : {} + end + + # window = windows.key?(i) ? windows[i] : {} + # door = doors.key?(i) ? doors[i] : {} + # skylight = skylights.key?(i) ? skylights[i] : {} sub = window unless window.empty? sub = door unless door.empty? @@ -2939,12 +2963,12 @@ def process(model = nil, argh = {}) up = argh[:uprate_walls] || argh[:uprate_roofs] || argh[:uprate_floors] uprate(model, tbd[:surfaces], argh) if up - # Derated (cloned) constructions are unique to each deratable surface. - # Unique construction names are prefixed with the surface name, - # and suffixed with " tbd", indicating that the construction is - # henceforth thermally derated. The " tbd" expression is also key in - # avoiding inadvertent derating - TBD will not derate constructions - # (or rather layered materials) having " tbd" in their OpenStudio name. + # A derated (cloned) construction and (cloned) insulating layer are unique + # to each deratable surface. Unique construction and material names are + # prefixed with the surface name, and suffixed with " tbd", indicating that + # the construction is henceforth thermally derated. The " tbd" expression + # is also key in avoiding inadvertent sequential derating - TBD will not + # derate a construction/material pair having " tbd" in their OpenStudio name. tbd[:surfaces].each do |id, surface| next unless surface.key?(:construction) next unless surface.key?(:index) @@ -3138,9 +3162,9 @@ def exit(runner = nil, argh = {}) argh[:uprate_walls ] = false unless argh.key?(:uprate_walls ) argh[:uprate_roofs ] = false unless argh.key?(:uprate_roofs ) argh[:uprate_floors] = false unless argh.key?(:uprate_floors) - argh[:wall_ut ] = 5.678 unless argh.key?(:wall_ut ) - argh[:roof_ut ] = 5.678 unless argh.key?(:roof_ut ) - argh[:floor_ut ] = 5.678 unless argh.key?(:floor_ut ) + argh[:wall_ut ] = UMAX unless argh.key?(:wall_ut ) + argh[:roof_ut ] = UMAX unless argh.key?(:roof_ut ) + argh[:floor_ut ] = UMAX unless argh.key?(:floor_ut ) argh[:wall_option ] = "" unless argh.key?(:wall_option ) argh[:roof_option ] = "" unless argh.key?(:roof_option ) argh[:floor_option ] = "" unless argh.key?(:floor_option ) diff --git a/lib/tbd/ua.rb b/lib/tbd/ua.rb index 41c1d5b..bfbedf9 100644 --- a/lib/tbd/ua.rb +++ b/lib/tbd/ua.rb @@ -54,12 +54,12 @@ def uo(model = nil, lc = nil, id = "", hloss = 0.0, film = 0.0, ut = 0.0) lyr[:index] = nil unless lyr[:index].is_a?(Numeric) lyr[:index] = nil unless lyr[:index] >= 0 lyr[:index] = nil unless lyr[:index] < lc.layers.size - return invalid("#{id} layer index", mth, 3, ERR, res) unless lyr[:index] + return invalid("#{id} layer index", mth, 3, WRN, res) unless lyr[:index] return zero("#{id}: heatloss" , mth, WRN, res) unless hloss > TOL return zero("#{id}: films" , mth, WRN, res) unless film > TOL - return zero("#{id}: Ut" , mth, WRN, res) unless ut > TOL - return invalid("#{id}: Ut" , mth, 6, WRN, res) unless ut < 5.678 - return zero("#{id}: net area (m2)", mth, ERR, res) unless area > TOL + return zero("#{id}: Ut" , mth, WRN, res) unless ut > UMIN + return invalid("#{id}: Ut" , mth, 6, WRN, res) unless ut < UMAX + return zero("#{id}: net area (m2)", mth, WRN, res) unless area > TOL # First, calculate initial layer RSi to initially meet Ut target. rt = 1 / ut # target construction Rt @@ -71,56 +71,51 @@ def uo(model = nil, lc = nil, id = "", hloss = 0.0, film = 0.0, ut = 0.0) u_psi = hloss / area # from psi+khi new_u -= u_psi # uprated layer USi to counter psi+khi new_r = 1 / new_u # uprated layer RSi to counter psi+khi - return zero("#{id}: new Rsi", mth, ERR, res) unless new_r > 0.001 + return zero("#{id}: new Rsi", mth, WRN, res) unless new_r > RMIN if lyr[:type] == :massless - m = lc.getLayer(lyr[:index]).to_MasslessOpaqueMaterial - return invalid("#{id} massless layer?", mth, 0, DBG, res) if m.empty? - - m = m.get.clone(model).to_MasslessOpaqueMaterial.get - m.setName("#{id} uprated") - new_r = 0.001 unless new_r > 0.001 - loss = (new_u - 1 / new_r) * area unless new_r > 0.001 - m.setThermalResistance(new_r) - else # type == :standard - m = lc.getLayer(lyr[:index]).to_StandardOpaqueMaterial - return invalid("#{id} standard layer?", mth, 0, DBG, res) if m.empty? - - m = m.get.clone(model).to_StandardOpaqueMaterial.get - m.setName("#{id} uprated") - k = m.thermalConductivity - - if new_r > 0.001 - d = new_r * k - - unless d > 0.003 - d = 0.003 - k = d / new_r - k = 3.0 unless k < 3.0 - loss = (new_u - k / d) * area unless k < 3.0 - end - else # new_r < 0.001 m2•K/W - d = 0.001 * k - d = 0.003 unless d > 0.003 - k = d / 0.001 unless d > 0.003 - loss = (new_u - k / d) * area + m = lc.getLayer(lyr[:index]).to_MasslessOpaqueMaterial + return invalid("#{id} massless layer?", mth, 0, DBG, res) if m.empty? + + m = m.get.clone(model).to_MasslessOpaqueMaterial.get + m.setName("#{id} uprated") + + new_r = RMIN unless new_r > RMIN + loss = (new_u - 1 / new_r) * area unless new_r > RMIN + + unless m.setThermalResistance(new_r) + return invalid("Can't uprate #{id}: RSi#{new_r.round(2)}", mth, 0, DBG, res) end + else + m = lc.getLayer(lyr[:index]).to_StandardOpaqueMaterial + return invalid("#{id} standard layer?", mth, 0, DBG, res) if m.empty? - if m.setThickness(d) - m.setThermalConductivity(k) - else - return invalid("Can't uprate #{id}: #{d} > 3m", mth, 0, ERR, res) + m = m.get.clone(model).to_StandardOpaqueMaterial.get + m.setName("#{id} uprated") + + d = m.thickness + k = (d / new_r).clamp(KMIN, KMAX) + d = (k * new_r).clamp(DMIN, DMAX) + + loss = (new_u - k / d) * area unless d / k > RMIN + + unless m.setThermalConductivity(k) + return invalid("Can't uprate #{id}: K#{k.round(3)}", mth, 0, DBG, res) + end + + unless m.setThickness(d) + return invalid("Can't uprate #{id}: #{(d*1000).to_i}mm", mth, 0, DBG, res) end end - return invalid("Can't ID insulating layer", mth, 0, ERR, res) unless m + return invalid("Can't ID insulating layer", mth, 0, DBG, res) unless m lc.setLayer(lyr[:index], m) uo = 1 / rsi(lc, film) if loss > TOL h_loss = format "%.3f", loss - return invalid("Can't assign #{h_loss} W/K to #{id}", mth, 0, ERR, res) + return invalid("Can't assign #{h_loss} W/K to #{id}", mth, 0, DBG, res) end res[:uo] = uo @@ -145,9 +140,9 @@ def uo(model = nil, lc = nil, id = "", hloss = 0.0, film = 0.0, ut = 0.0) # @option argh [Bool] :uprate_walls (false) whether to uprate walls # @option argh [Bool] :uprate_roofs (false) whether to uprate roofs # @option argh [Bool] :uprate_floors (false) whether to uprate floors - # @option argh [#to_f] :wall_ut (5.678) uprated wall Usi-factor target - # @option argh [#to_f] :roof_ut (5.678) uprated roof Usi-factor target - # @option argh [#to_f] :floor_ut (5.678) uprated floor Usi-factor target + # @option argh [#to_f] :wall_ut (UMAX) uprated wall Usi-factor target + # @option argh [#to_f] :roof_ut (UMAX) uprated roof Usi-factor target + # @option argh [#to_f] :floor_ut (UMAX) uprated floor Usi-factor target # @option argh [#to_s] :wall_option ("") construction to uprate (or "all") # @option argh [#to_s] :roof_option ("") construction to uprate (or "all") # @option argh [#to_s] :floor_option ("") construction to uprate (or "all") @@ -172,9 +167,9 @@ def uprate(model = nil, s = {}, argh = {}) argh[:uprate_walls ] = false unless argh.key?(:uprate_walls) argh[:uprate_roofs ] = false unless argh.key?(:uprate_roofs) argh[:uprate_floors] = false unless argh.key?(:uprate_floors) - argh[:wall_ut ] = 5.678 unless argh.key?(:wall_ut) - argh[:roof_ut ] = 5.678 unless argh.key?(:roof_ut) - argh[:floor_ut ] = 5.678 unless argh.key?(:floor_ut) + argh[:wall_ut ] = UMAX unless argh.key?(:wall_ut) + argh[:roof_ut ] = UMAX unless argh.key?(:roof_ut) + argh[:floor_ut ] = UMAX unless argh.key?(:floor_ut) argh[:wall_option ] = "" unless argh.key?(:wall_option) argh[:roof_option ] = "" unless argh.key?(:roof_option) argh[:floor_option ] = "" unless argh.key?(:floor_option) @@ -197,7 +192,7 @@ def uprate(model = nil, s = {}, argh = {}) groups.each do |type, g| next unless g[:up] next unless g[:ut].is_a?(Numeric) - next unless g[:ut] < 5.678 + next unless g[:ut] < UMAX next if g[:ut] < 0 typ = type @@ -211,7 +206,7 @@ def uprate(model = nil, s = {}, argh = {}) all = tout.include?(op) if g[:op].empty? - log(ERR, "Construction (#{type}) to uprate? (#{mth})") + log(WRN, "Construction (#{type}) to uprate? (#{mth})") elsif all s.each do |nom, surface| next unless surface.key?(:deratable ) @@ -247,11 +242,11 @@ def uprate(model = nil, s = {}, argh = {}) else id = g[:op] lc = model.getConstructionByName(id) - log(ERR, "Construction '#{id}'? (#{mth})") if lc.empty? + log(WRN, "Construction '#{id}'? (#{mth})") if lc.empty? next if lc.empty? lc = lc.get.to_LayeredConstruction - log(ERR, "'#{id}' layered construction? (#{mth})") if lc.empty? + log(WRN, "'#{id}' layered construction? (#{mth})") if lc.empty? next if lc.empty? lc = lc.get @@ -282,7 +277,7 @@ def uprate(model = nil, s = {}, argh = {}) end if coll.empty? - log(ERR, "No #{type} construction to uprate - skipping (#{mth})") + log(WRN, "No #{type} construction to uprate - skipping (#{mth})") next elsif lc # Valid layered construction - good to uprate! @@ -291,7 +286,7 @@ def uprate(model = nil, s = {}, argh = {}) lyr[:index] = nil unless lyr[:index] >= 0 lyr[:index] = nil unless lyr[:index] < lc.layers.size - log(ERR, "Insulation index for '#{id}'? (#{mth})") unless lyr[:index] + log(WRN, "Insulation index for '#{id}'? (#{mth})") unless lyr[:index] next unless lyr[:index] # Ensure lc is exclusively linked to deratable surfaces of right type. @@ -302,10 +297,10 @@ def uprate(model = nil, s = {}, argh = {}) next unless surface.key?(:construction) next unless surface[:construction].is_a?(cl3) next unless surface[:construction] == lc + next unless surface[:deratable] ok = true - ok = false unless surface[:type ] == typ - ok = false unless surface[:deratable] + ok = false unless surface[:type] == typ ok = false unless coll.key?(id) ok = false unless coll[id][:s].key?(nom) @@ -346,13 +341,18 @@ def uprate(model = nil, s = {}, argh = {}) if sss.isConstructionDefaulted set = defaultConstructionSet(sss) # building? story? - constructions = set.defaultExteriorSurfaceConstructions - unless constructions.empty? - constructions = constructions.get - constructions.setWallConstruction(lc) if typ == :wall - constructions.setFloorConstruction(lc) if typ == :floor - constructions.setRoofCeilingConstruction(lc) if typ == :ceiling + if set.nil? + sss.setConstruction(lc) + else + constructions = set.defaultExteriorSurfaceConstructions + + unless constructions.empty? + constructions = constructions.get + constructions.setWallConstruction(lc) if typ == :wall + constructions.setFloorConstruction(lc) if typ == :floor + constructions.setRoofCeilingConstruction(lc) if typ == :ceiling + end end else sss.setConstruction(lc) @@ -385,7 +385,7 @@ def uprate(model = nil, s = {}, argh = {}) res = uo(model, lc, id, hloss, film, g[:ut]) unless res[:uo] && res[:m] - log(ERR, "Unable to uprate '#{id}' (#{mth})") + log(WRN, "Unable to uprate '#{id}' (#{mth})") next end @@ -407,7 +407,7 @@ def uprate(model = nil, s = {}, argh = {}) argh[:roof_uo ] = res[:uo] if typ == :ceiling argh[:floor_uo] = res[:uo] if typ == :floor else - log(ERR, "Nilled construction to uprate - (#{mth})") + log(WRN, "Nilled construction to uprate - (#{mth})") return false end end @@ -589,7 +589,7 @@ def ua_summary(date = Time.now, argh = {}) empty = shorts[:has].empty? && shorts[:val].empty? has = shorts[:has] unless empty val = shorts[:val] unless empty - log(ERR, "Invalid UA' reference set (#{mth})") if empty + log(WRN, "Invalid UA' reference set (#{mth})") if empty unless empty ua[:model] += " : Design vs '#{ref}'" @@ -1000,7 +1000,7 @@ def ua_md(ua = {}, lang = :en) model = "* modèle : #{ua[:file]}" if ua.key?(:file) && lang == :fr model += " (v#{ua[:version]})" if ua.key?(:version) report << model unless model.empty? - report << "* TBD : v3.4.5" + report << "* TBD : v3.5.0" report << "* date : #{ua[:date]}" if lang == :en diff --git a/lib/tbd/version.rb b/lib/tbd/version.rb index 9f174ca..3634cf3 100644 --- a/lib/tbd/version.rb +++ b/lib/tbd/version.rb @@ -21,5 +21,5 @@ # SOFTWARE. module TBD - VERSION = "3.4.5".freeze + VERSION = "3.5.0".freeze end diff --git a/spec/tbd_tests_spec.rb b/spec/tbd_tests_spec.rb index 9e648b8..b6fae36 100644 --- a/spec/tbd_tests_spec.rb +++ b/spec/tbd_tests_spec.rb @@ -9,6 +9,14 @@ WRN = TBD::WRN.dup ERR = TBD::ERR.dup FTL = TBD::FTL.dup + DMIN = TBD::DMIN.dup + DMAX = TBD::DMAX.dup + KMIN = TBD::KMIN.dup + KMAX = TBD::KMAX.dup + UMAX = TBD::UMAX.dup + UMIN = TBD::UMIN.dup + RMIN = TBD::RMIN.dup + RMAX = TBD::RMAX.dup it "can process JSON surface KHI entries" do translator = OpenStudio::OSVersion::VersionTranslator.new @@ -1565,69 +1573,6 @@ model.save(file, true) end - it "can purge KIVA objects" do - translator = OpenStudio::OSVersion::VersionTranslator.new - TBD.clean! - - file = File.join(__dir__, "files/osms/out/seb_KIVA.osm") - path = OpenStudio::Path.new(file) - model = translator.loadModel(path) - expect(model).to_not be_empty - model = model.get - - expect(model.foundationKivaSettings).to be_empty - expect(model.getSurfacePropertyExposedFoundationPerimeters.size).to eq(1) - expect(model.getFoundationKivas.size).to eq(4) - - adjacents = 0 - foundation = nil - - model.getSurfaces.each do |surface| - next unless surface.isGroundSurface - next if surface.adjacentFoundation.empty? - - adjacents += 1 - foundation = surface.adjacentFoundation.get - expect(surface.surfacePropertyExposedFoundationPerimeter).to_not be_empty - expect(surface.outsideBoundaryCondition.downcase).to eq("foundation") - end - - expect(adjacents).to eq(1) - expect(foundation).to be_a(OpenStudio::Model::FoundationKiva) - - # Add 2x custom blocks for testing. - xps = model.getMaterialByName("XPS_38mm") - expect(xps).to_not be_empty - xps = xps.get - expect(foundation.addCustomBlock(xps, 0.1, 0.1, -0.5)).to be true - expect(foundation.addCustomBlock(xps, 0.2, 0.2, -1.5)).to be true - - blocks = foundation.customBlocks - expect(blocks).to_not be_empty - - blocks.each { |block| expect(block.material).to eq(xps) } - - # Purge. - expect(TBD.resetKIVA(model, "Ground")).to be true - expect(model.foundationKivaSettings).to be_empty - expect(model.getSurfacePropertyExposedFoundationPerimeters).to be_empty - expect(model.getFoundationKivas).to be_empty - expect(TBD.info?).to be true - expect(TBD.logs.size).to eq(1) - expect(TBD.logs.first[:message]).to include("Purged KIVA objects from ") - - model.getSurfaces.each do |surface| - next unless surface.isGroundSurface - - expect(surface.adjacentFoundation).to be_empty - expect(surface.surfacePropertyExposedFoundationPerimeter).to be_empty - expect(surface.outsideBoundaryCondition).to eq("Ground") - end - - file = File.join(__dir__, "files/osms/out/seb_noKIVA.osm") - model.save(file, true) - end - it "can test 5ZoneNoHVAC (failed) uprating" do translator = OpenStudio::OSVersion::VersionTranslator.new TBD.clean! @@ -1703,8 +1648,8 @@ # linear thermal bridges is very high given the limited exposed (gross) # area. If area-weighted, derating the insulation layer of the referenced # wall construction above would entail factoring in this extra thermal - # conductance of ~0.309 W/m2•K (84.6/273.6), which would reduce the - # insulation thickness quite significantly. + # conductance of ~0.309 W/m2•K (84.6/273.6), which would increase the + # insulation conductivity quite significantly. # # Ut = Uo + ( ∑psi • L )/A # @@ -1757,7 +1702,9 @@ # # The method exits with an ERROR in 2x cases: # - calculated Uo is negative, i.e. ( ∑psi • L )/A > 0.277 - # - calculated layer r violates E+ material constraints (e.g. too thin) + # - calculated layer r violates E+ material constraints, e.g. + # - too conductive + # - too thin # -- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- -- # # Retrying the previous example, yet requesting uprating calculations: @@ -1782,7 +1729,7 @@ expect(json).to have_key(:surfaces) io = json[:io ] surfaces = json[:surfaces] - expect(TBD.error?).to be true + expect(TBD.warn?).to be true expect(TBD.logs.size).to eq(2) expect(TBD.logs.first[:message]).to include("Zero") expect(TBD.logs.first[:message]).to include(": new Rsi") @@ -1846,10 +1793,10 @@ insul = insul.get expect(insul.nameString).to include(" uprated m tbd") - expect(insul.thermalConductivity).to be_within(0.0001).of(0.0432) - th1 = (insul.thickness - 0.191).abs < 0.001 # derated layer Rsi 4.42 - th2 = (insul.thickness - 0.186).abs < 0.001 # derated layer Rsi 4.31 - expect(th1 || th2).to be true # depending if 'short' or 'long' walls + k1 = (insul.thermalConductivity - 0.0261).round(4) == 0 + k2 = (insul.thermalConductivity - 0.0253).round(4) == 0 + expect(k1 || k2).to be true + expect(insul.thickness).to be_within(0.0001).of(0.1120) end walls.each do |wall| @@ -1924,21 +1871,45 @@ # layer thickness limit, harmonizing with EnergyPlus: # # https://github.com/NREL/OpenStudio/pull/4622 - if OpenStudio.openStudioVersion.split(".").join.to_i < 350 - expect(TBD.error?).to be true - expect(TBD.logs).to_not be_empty - expect(TBD.logs.size).to eq(2) + # + # This didn't mean EnergyPlus wouldn't halt a simulation due to invalid CTF + # calculations - happens with very thick materials. Recent 2025 TBD changes + # have removed this check. Users of pre-v3.5.X OpenStudio should expect + # OS-generated simulation failures when uprating (extremes cases). Achtung! + expect(TBD.status).to be_zero + expect(argh).to have_key(:wall_uo) + expect(argh[:wall_uo]).to be_within(0.0001).of(UMIN) # RSi 100 (R568) - expect(TBD.logs.first[:message]).to include("Invalid") - expect(TBD.logs.first[:message]).to include("Can't uprate ") - expect(TBD.logs.last[:message ]).to include("Unable to uprate") + nb = 0 - expect(argh).to_not have_key(:wall_uo) - else - expect(TBD.status).to be_zero - expect(argh).to have_key(:wall_uo) - expect(argh[:wall_uo]).to be_within(0.0001).of(0.0089) # RSi 112 (R638) + model.getSurfaces.each do |s| + next unless s.surfaceType.downcase == "wall" + + c = s.construction + expect(c).to_not be_empty + c = c.get.to_LayeredConstruction + next if c.empty? + + c = c.get + next unless c.nameString.include?("c tbd") + + lyr = TBD.insulatingLayer(c) + expect(lyr).to be_a(Hash) + expect(lyr).to have_key(:type) + expect(lyr).to have_key(:index) + expect(lyr).to have_key(:r) + expect(lyr[:type]).to eq(:standard) + expect(lyr[:index]).to be_between(0, c.numLayers) + insul = c.getLayer(lyr[:index]) + insul = insul.to_StandardOpaqueMaterial + expect(insul).to_not be_empty + insul = insul.get + expect(insul.thickness).to be_within(TOL).of(1.00) + + nb += 1 end + + expect(nb).to eq(4) end it "can test Hash inputs" do @@ -2035,7 +2006,6 @@ it "can check for attics vs plenums" do translator = OpenStudio::OSVersion::VersionTranslator.new TBD.clean! - # Outdoor-facing surfaces of UNCONDITIONED spaces are never derated by TBD. # Yet determining whether an OpenStudio space should be considered # UNCONDITIONED (e.g. an attic), rather than INDIRECTLYCONDITIONED @@ -2168,6 +2138,95 @@ expect(attic.additionalProperties.resetFeature(key)).to be true + # Adding a sub surface between UNCONDITIONED Attic & CONDITIONED Core. + file = File.join(__dir__, "files/osms/in/smalloffice.osm") + path = OpenStudio::Path.new(file) + model = translator.loadModel(path) + expect(model).to_not be_empty + model = model.get + + floor = model.getSurfaceByName("Attic_floor_core") + expect(floor).to_not be_empty + floor = floor.get + + ceiling = floor.adjacentSurface + expect(ceiling).to_not be_empty + ceiling = ceiling.get + + sub = {} + sub[:id ] = "attic trap door" + sub[:type ] = "Door" + sub[:assembly] = TBD.genConstruction(model, {type: :door}) + sub[:width ] = 1.0 + sub[:height ] = 1.0 + expect(TBD.addSubs(floor, sub, false, true, true)).to be true + expect(TBD.addSubs(ceiling, sub, false, true, false)).to be true + expect(floor.subSurfaces.size).to eq(1) + expect(ceiling.subSurfaces.size).to eq(1) + trap = floor.subSurfaces.first + door = ceiling.subSurfaces.first + expect(trap.setAdjacentSubSurface(door)).to be true + expect(door.setAdjacentSubSurface(trap)).to be true + expect(trap.adjacentSubSurface).to_not be_empty + expect(door.adjacentSubSurface).to_not be_empty + expect(trap.adjacentSubSurface.get).to eq(door) + expect(door.adjacentSubSurface.get).to eq(trap) + + argh = { option: "code (Quebec)" } + json = TBD.process(model, argh) + puts TBD.logs + expect(TBD.status).to be_zero + expect(json).to be_a(Hash) + expect(json).to have_key(:io) + expect(json).to have_key(:surfaces) + io = json[:io ] + surfaces = json[:surfaces] + expect(surfaces).to be_a(Hash) + expect(surfaces.size).to eq(43) + expect(io).to have_key(:edges) + expect(io[:edges].size).to eq(109) + + file = File.join(__dir__, "files/osms/out/trapdoor.osm") + model.save(file, true) + + # Adding skylights/wells. + file = File.join(__dir__, "files/osms/in/smalloffice.osm") + path = OpenStudio::Path.new(file) + model = translator.loadModel(path) + expect(model).to_not be_empty + model = model.get + + srr = 0.05 + gra = TBD.grossRoofArea(model.getSpaces) + tm2 = srr * gra + rm2 = TBD.addSkyLights(model.getSpaces, {area: tm2}) + puts TBD.logs unless TBD.logs.empty? + expect(TBD.status).to be_zero + expect(rm2.round(2)).to eq(gra.round(2)) + + argh = {} + argh[:option ] = "efficient (BETBG)" + argh[:uprate_walls] = true + argh[:uprate_roofs] = true + argh[:wall_option ] = "ALL wall constructions" + argh[:roof_option ] = "ALL roof constructions" + argh[:wall_ut ] = 0.215 # NECB 2020 CZ7A (RSi 4.65 / R26) + argh[:roof_ut ] = 0.121 # NECB 2020 CZ7A (RSi 8.26 / R47) + json = TBD.process(model, argh) + expect(TBD.status).to be_zero + expect(json).to be_a(Hash) + expect(json).to have_key(:io) + expect(json).to have_key(:surfaces) + io = json[:io ] + surfaces = json[:surfaces] + expect(surfaces).to be_a(Hash) + expect(surfaces.size).to eq(79) + expect(io).to have_key(:edges) + expect(io[:edges].size).to eq(173) + + file = File.join(__dir__, "files/osms/out/office_attic_sky.osm") + model.save(file, true) + # -- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- -- # # 5Zone_2 test case (as INDIRECTLYCONDITIONED plenum). plenum_walls = [] @@ -3101,6 +3160,9 @@ puts TBD.logs unless TBD.logs.empty? expect(TBD.status).to be_zero + + file = File.join(__dir__, "files/osms/out/seb2_sky2.osm") + model.save(file, true) end it "can factor in negative PSI-factors (JSON input)" do diff --git a/tbd.gemspec b/tbd.gemspec index 87a4ad5..a03bdfe 100644 --- a/tbd.gemspec +++ b/tbd.gemspec @@ -29,7 +29,7 @@ Gem::Specification.new do |s| s.metadata = {} s.add_dependency "topolys", "~> 0" - s.add_dependency "osut", "~> 0.7.0" + s.add_dependency "osut", "~> 0.8.0" s.add_dependency "json-schema", "~> 4" s.add_development_dependency "bundler", "~> 2.1"