From 130a7b8687cf8f8370541c4c6834caf89cd44685 Mon Sep 17 00:00:00 2001 From: brgix Date: Tue, 31 Mar 2026 08:01:07 -0400 Subject: [PATCH 01/14] Updates OSut gem files --- Gemfile | 2 + lib/measures/tbd/measure.xml | 6 +-- lib/measures/tbd/resources/utils.rb | 57 ++++++++++++++++++++++------- lib/tbd/version.rb | 2 +- tbd.gemspec | 3 +- 5 files changed, 52 insertions(+), 18 deletions(-) diff --git a/Gemfile b/Gemfile index b4e2a20..54ddd49 100644 --- a/Gemfile +++ b/Gemfile @@ -1,3 +1,5 @@ source "https://rubygems.org" +gem "osut", git: "https://github.com/rd2/osut", branch: "airfilm" + gemspec diff --git a/lib/measures/tbd/measure.xml b/lib/measures/tbd/measure.xml index c24d529..d0d0848 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 - 833dbf17-e51f-43c7-9c8b-a79ef0e6bd3b - 2026-02-03T13:53:54Z + eb14be0d-a469-43ba-b02e-6faf5ac6a5c8 + 2026-03-31T11:57:38Z 99772807 TBDMeasure Thermal Bridging and Derating - TBD @@ -547,7 +547,7 @@ utils.rb rb resource - 118F3A32 + C54903AD version.rb diff --git a/lib/measures/tbd/resources/utils.rb b/lib/measures/tbd/resources/utils.rb index 2e96f59..daf2c8b 100644 --- a/lib/measures/tbd/resources/utils.rb +++ b/lib/measures/tbd/resources/utils.rb @@ -106,26 +106,28 @@ module OSut # default inside + outside air film resistances (m2.K/W) @@film = { shading: 0.000, # NA - partition: 0.150, # uninsulated wood- or steel-framed wall - wall: 0.150, # un/insulated wall - roof: 0.140, # un/insulated roof - floor: 0.190, # un/insulated (exposed) floor - basement: 0.120, # un/insulated basement wall - slab: 0.160, # un/insulated basement slab or slab-on-grade + ceiling: 0.267, # interzone floor/ceiling + partition: 0.240, # interzone wall partition + wall: 0.150, # exposed wall + roof: 0.135, # exposed roof + floor: 0.192, # exposed floor + basement: 0.120, # basement wall + slab: 0.162, # basement slab or slab-on-grade door: 0.150, # standard, 45mm insulated steel (opaque) door window: 0.150, # vertical fenestration, e.g. glazed doors, windows - skylight: 0.140 # e.g. domed 4' x 4' skylight + skylight: 0.135 # e.g. domed 4' x 4' skylight }.freeze # default (~1980s) envelope Uo (W/m2•K), based on surface type @@uo = { shading: nil, # N/A + ceiling: nil, # N/A partition: nil, # N/A wall: 0.384, # rated R14.8 hr•ft2F/Btu roof: 0.327, # rated R17.6 hr•ft2F/Btu floor: 0.317, # rated R17.9 hr•ft2F/Btu (exposed floor) - basement: nil, - slab: nil, + basement: nil, # N/A + slab: nil, # N/A door: 1.800, # insulated, unglazed steel door (single layer) window: 2.800, # e.g. patio doors (simple glazing) skylight: 3.500 # all skylight technologies @@ -600,10 +602,6 @@ def genConstruction(model = nil, specs = {}) return mismatch("model", model, cl1, mth) unless model.is_a?(cl1) return mismatch("specs", specs, cl2, mth) unless specs.is_a?(cl2) - specs[:id] = "" unless specs.key?(:id) - id = trim(specs[:id]) - id = "OSut:CON:#{specs[:type]}" if id.empty? - if specs.key?(:type) unless @@uo.keys.include?(specs[:type]) return invalid("surface type", mth, 2, ERR) @@ -612,6 +610,10 @@ def genConstruction(model = nil, specs = {}) specs[:type] = :wall end + specs[:id] = "" unless specs.key?(:id) + id = trim(specs[:id]) + id = "OSut:CON:#{specs[:type]}" if id.empty? + specs[:uo] = @@uo[ specs[:type] ] unless specs.key?(:uo) # can be nil u = specs[:uo] @@ -652,6 +654,35 @@ 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]}" + when :ceiling + unless specs[:clad] == :none + mt = :concrete + mt = :material if specs[:clad] == :light + d = 0.015 + d = 0.100 if specs[:clad] == :medium + d = 0.200 if specs[:clad] == :heavy + a[:clad][:mat] = @@mats[mt] + a[:clad][:d ] = d + a[:clad][:id ] = "OSut:#{mt}:#{format('%03d', d*1000)[-3..-1]}" + end + + mt = :mineral + mt = :polyiso if specs[:frame] == :medium + mt = :cellulose if specs[:frame] == :heavy + mt = :material unless u + d = 0.100 + 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]}" + + 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]}" + end when :partition unless specs[:clad] == :none d = 0.015 diff --git a/lib/tbd/version.rb b/lib/tbd/version.rb index cd125f1..43e03c2 100644 --- a/lib/tbd/version.rb +++ b/lib/tbd/version.rb @@ -21,5 +21,5 @@ # SOFTWARE. module TBD - VERSION = "3.5.2".freeze + VERSION = "3.5.3".freeze end diff --git a/tbd.gemspec b/tbd.gemspec index 14956fb..b609b28 100644 --- a/tbd.gemspec +++ b/tbd.gemspec @@ -29,9 +29,10 @@ Gem::Specification.new do |s| s.metadata = {} s.add_dependency "topolys", "~> 0" - s.add_dependency "osut", "~> 0" + # s.add_dependency "osut", "~> 0" s.add_dependency "json-schema", "~> 4" + s.add_development_dependency "osut", "~> 0.8.3" s.add_development_dependency "bundler", "~> 2.1" s.add_development_dependency "rake", "~> 13.0" s.add_development_dependency "rspec", "~> 3.11" From dda22ede6ad06779f23fd60f149ac6d41b772d42 Mon Sep 17 00:00:00 2001 From: brgix Date: Thu, 2 Apr 2026 08:18:55 -0400 Subject: [PATCH 02/14] Updates OSut 'filmResistances' --- lib/measures/tbd/measure.xml | 6 +-- lib/measures/tbd/resources/utils.rb | 59 ++++++++++++++++++++++++++++- spec/tbd_tests_spec.rb | 10 ++++- 3 files changed, 68 insertions(+), 7 deletions(-) diff --git a/lib/measures/tbd/measure.xml b/lib/measures/tbd/measure.xml index d0d0848..d09a1ca 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 - eb14be0d-a469-43ba-b02e-6faf5ac6a5c8 - 2026-03-31T11:57:38Z + 3d410488-5a03-4645-8d1f-300d24ad8525 + 2026-04-02T12:14:15Z 99772807 TBDMeasure Thermal Bridging and Derating - TBD @@ -547,7 +547,7 @@ utils.rb rb resource - C54903AD + BAFE3A46 version.rb diff --git a/lib/measures/tbd/resources/utils.rb b/lib/measures/tbd/resources/utils.rb index daf2c8b..5854c2a 100644 --- a/lib/measures/tbd/resources/utils.rb +++ b/lib/measures/tbd/resources/utils.rb @@ -106,8 +106,8 @@ module OSut # default inside + outside air film resistances (m2.K/W) @@film = { shading: 0.000, # NA - ceiling: 0.267, # interzone floor/ceiling - partition: 0.240, # interzone wall partition + ceiling: 0.266, # interzone floor/ceiling + partition: 0.239, # interzone wall partition wall: 0.150, # exposed wall roof: 0.135, # exposed roof floor: 0.192, # exposed floor @@ -199,6 +199,61 @@ module OSut @@mats[:door ][:rho] = 600.000 @@mats[:door ][:cp ] = 1000.000 + ## + # Returns surface air film resistance(s). Surface tilt-dependent values are + # returned if a valid surface tilt [0, PI] is provided. Otherwise, generic + # tilt-independent air film resistances are returned instead. + # + # @param [:to_sym] surface type, e.g. :roof, :wall, :partition, :ceiling + # @param [Numeric] surface tilt (in rad), optional + # + # @return [Float] surface air film resistance(s) + # @return [0.0] if invalid input (see logs) + def filmResistances(type = :wall, tilt = 2 * Math::PI) + mth = "OSut::#{__callee__}" + + unless tilt.is_a?(Numeric) + return mismatch("tilt", tilt, Float, mth, DBG, 0.0) + end + + unless type.respond_to?(:to_sym) + return mismatch("type", type, Symbol, mth, DBG, 0.0) + end + + type = type.to_s.downcase.to_sym + + unless @@film.key?(type) + return invalid("type", mth, 1, DBG, 0.0) + end + + # Generic, tilt-independent values. + r = @@film[type] + return r if type == :shading + + # Valid tilt? + if tilt.between?(0, Math::PI) + r = OpenStudio::Model::PlanarSurface.stillAirFilmResistance(tilt) + return r if type == :basement || type == :slab + + if type == :ceiling || type == :partition + # Interzone. Fetch reciprocal tilt, e.g. if tilt == 0°, tiltx = 180° + tiltx = tilt + Math::PI + + # Assuming tilt is contrained [0°, 180°] - constrain tiltx [0° 180°]: + # e.g. tiltx == 210° if tilt == 30°, so convert tiltx to 150° + # e.g. tiltx == 330° if tilt == 150°, so convert tiltx to 30° + # e.g. tiltx == 275° if tilt == 95°, so convert tiltx to 85° + tiltx = Math::PI - tilt if tiltx > Math::PI + + r += OpenStudio::Model::PlanarSurface.stillAirFilmResistance(tiltx) + else + r += 0.03 # "MOVINGAIR_15MPH" + end + end + + r + end + ## # Validates if every material in a layered construction is standard & opaque. # diff --git a/spec/tbd_tests_spec.rb b/spec/tbd_tests_spec.rb index b6fae36..a32634d 100644 --- a/spec/tbd_tests_spec.rb +++ b/spec/tbd_tests_spec.rb @@ -2174,7 +2174,7 @@ argh = { option: "code (Quebec)" } json = TBD.process(model, argh) - puts TBD.logs + puts TBD.logs unless TBD.logs.empty? expect(TBD.status).to be_zero expect(json).to be_a(Hash) expect(json).to have_key(:io) @@ -2559,7 +2559,13 @@ c = c.get.to_LayeredConstruction expect(c).to_not be_empty c = c.get - expect(TBD.rsi(c, s.filmResistance)).to be_within(TOL).of(6.38) + + rsi1 = s.filmResistance + rsi2 = TBD.filmResistances(:roof) + rsi3 = TBD.filmResistances(:roof, s.tilt) + expect(TBD.rsi(c, rsi1)).to be_within(TOL).of(6.38) + expect(TBD.rsi(c, rsi2)).to be_within(TOL).of(6.31) + expect(TBD.rsi(c, rsi3)).to be_within(TOL).of(6.31) construction = c if construction.nil? expect(c).to eq(construction) From d7fb22570a80f4676b030a99abce59bb6ac552ff Mon Sep 17 00:00:00 2001 From: brgix Date: Thu, 2 Apr 2026 15:48:04 -0400 Subject: [PATCH 03/14] Initial tests of OSut 'filmResistances' --- lib/measures/tbd/measure.xml | 8 +++---- lib/measures/tbd/resources/geo.rb | 36 ++++++++++++++++++++----------- lib/measures/tbd/resources/psi.rb | 23 ++++++++++++-------- lib/tbd/geo.rb | 36 ++++++++++++++++++++----------- lib/tbd/psi.rb | 23 ++++++++++++-------- spec/tbd_tests_spec.rb | 20 ++++++++++------- 6 files changed, 90 insertions(+), 56 deletions(-) diff --git a/lib/measures/tbd/measure.xml b/lib/measures/tbd/measure.xml index d09a1ca..b9ebbfc 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 - 3d410488-5a03-4645-8d1f-300d24ad8525 - 2026-04-02T12:14:15Z + 42594ae8-7b45-4a0b-aff8-d3fafee576d0 + 2026-04-02T19:46:02Z 99772807 TBDMeasure Thermal Bridging and Derating - TBD @@ -499,7 +499,7 @@ geo.rb rb resource - 9CA80CEB + 86AE9E8B geometry.rb @@ -523,7 +523,7 @@ psi.rb rb resource - B9FB5E02 + 23EDD2E7 tbd.rb diff --git a/lib/measures/tbd/resources/geo.rb b/lib/measures/tbd/resources/geo.rb index 00aeae4..4eab210 100644 --- a/lib/measures/tbd/resources/geo.rb +++ b/lib/measures/tbd/resources/geo.rb @@ -299,14 +299,16 @@ def properties(surface = nil, argh = {}) return invalid("#{nom} normal", mth, 0, ERR) unless n type = surface.surfaceType.downcase - facing = surface.outsideBoundaryCondition + facing = surface.outsideBoundaryCondition.downcase + interz = false setpts = setpoints(space) - if facing.downcase == "surface" - empty = surface.adjacentSurface.empty? - return invalid("#{nom}: adjacent surface", mth, 0, ERR) if empty + if facing == "surface" + adj = surface.adjacentSurface + return invalid("#{nom}: adjacent surface", mth, 0, ERR) if adj.empty? - facing = surface.adjacentSurface.get.nameString + facing = adj.get.nameString + interz = true end unless surface.construction.empty? @@ -315,8 +317,9 @@ def properties(surface = nil, argh = {}) unless lc.empty? lc = lc.get lyr = insulatingLayer(lc) + idx = lyr[:index] - if lyr[:index].is_a?(Integer) && lyr[:index].between?(0, lc.numLayers - 1) + if idx.is_a?(Integer) && idx.between?(0, lc.numLayers - 1) surf[:construction] = lc # index: ... of layer/material (to derate) within construction # ltype: either :massless (RSi) or :standard (k + d) @@ -358,8 +361,14 @@ def properties(surface = nil, argh = {}) surf[:story ] = story.get unless story.empty? surf[:n ] = n surf[:gross ] = surface.grossArea - surf[:filmRSI ] = surface.filmResistance surf[:spandrel ] = spandrel?(surface) + surf[:filmRSI ] = surface.filmResistance + + if interz + typ = :ceiling # interzone roof or ceiling + typ = :partition if surf[:type] == :wall + surf[:filmRSI] = TBD.filmResistances(typ, surface.tilt) + end surface.subSurfaces.sort_by { |s| s.nameString }.each do |s| next if poly(s).empty? @@ -508,7 +517,8 @@ def properties(surface = nil, argh = {}) end unless u.is_a?(Numeric) - r = rsi(c, surface.filmResistance) + # r = rsi(c, surface.filmResistance) + r = rsi(c, surf[:filmRSI]) if r < TOL log(ERR, "Skipping '#{id}': U-factor unavailable (#{mth})") @@ -831,7 +841,7 @@ def kiva(model = nil, walls = {}, floors = {}, edges = {}) edge[:surfaces].keys.each do |id| next unless floors.key?(id) - next unless floors[id][:boundary].downcase == "foundation" + next unless floors[id][:boundary] == "foundation" next if floors[id].key?(:kiva) # Initially set as slab-on-grade. Track 'exposed foundation perimeter'. @@ -845,7 +855,7 @@ def kiva(model = nil, walls = {}, floors = {}, edges = {}) edge[:surfaces].keys.each do |i| next if i == id next unless walls.key?(i) - next unless walls[i][:boundary].downcase == "foundation" + next unless walls[i][:boundary] == "foundation" next if walls[i].key?(:kiva) floors[id][:kiva ] = :basement @@ -857,7 +867,7 @@ def kiva(model = nil, walls = {}, floors = {}, edges = {}) edge[:surfaces].keys.each do |i| next if i == id next unless walls.key?(i) - next unless walls[i][:boundary].downcase == "outdoors" + next unless walls[i][:boundary] == "outdoors" floors[id][:exposed] += edge[:length] end @@ -872,7 +882,7 @@ def kiva(model = nil, walls = {}, floors = {}, edges = {}) e[:surfaces].keys.each do |ii| next if i == ii next unless walls.key?(ii) - next unless walls[ii][:boundary].downcase == "foundation" + next unless walls[ii][:boundary] == "foundation" next if walls[ii].key?(:kiva) floors[id][:kiva ] = :basement @@ -883,7 +893,7 @@ def kiva(model = nil, walls = {}, floors = {}, edges = {}) e[:surfaces].keys.each do |ii| next if i == ii next unless walls.key?(ii) - next unless walls[ii][:boundary].downcase == "outdoors" + next unless walls[ii][:boundary] == "outdoors" floors[id][:exposed] += e[:length] end diff --git a/lib/measures/tbd/resources/psi.rb b/lib/measures/tbd/resources/psi.rb index 7c37714..0f1d747 100644 --- a/lib/measures/tbd/resources/psi.rb +++ b/lib/measures/tbd/resources/psi.rb @@ -1555,7 +1555,7 @@ def process(model = nil, argh = {}) next unless surface[:conditioned] next if surface[:ground ] - unless surface[:boundary].downcase == "outdoors" + unless surface[:boundary] == "outdoors" next unless tbd[:surfaces].key?(surface[:boundary]) next if tbd[:surfaces][surface[:boundary]][:conditioned] end @@ -2202,7 +2202,7 @@ def process(model = nil, argh = {}) next if holes.key?(i) next if shades.key?(i) - facing = tbd[:surfaces][i][:boundary].downcase + facing = tbd[:surfaces][i][:boundary] next unless facing == "othersidecoefficients" s1 = edge[:surfaces][id] @@ -2971,6 +2971,7 @@ def process(model = nil, argh = {}) # 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?(:filmRSI) next unless surface.key?(:index) next unless surface.key?(:ltype) next unless surface.key?(:r) @@ -2995,7 +2996,8 @@ def process(model = nil, argh = {}) if m c.setLayer(index, m) c.setName("#{id} c tbd") - current_R = rsi(current_c, s.filmResistance) + # current_R = rsi(current_c, s.filmResistance) + current_R = rsi(current_c, surface[:filmRSI]) # In principle, the derated "ratio" could be calculated simply by # accessing a surface's uFactor. Yet air layers within constructions @@ -3035,7 +3037,8 @@ def process(model = nil, argh = {}) # Compute updated RSi value from layers. updated_c = s.construction.get.to_LayeredConstruction.get - updated_R = rsi(updated_c, s.filmResistance) + # updated_R = rsi(updated_c, s.filmResistance) + updated_R = rsi(updated_c, surface[:filmRSI]) ratio = -(current_R - updated_R) * 100 / current_R surface[:ratio] = ratio if ratio.abs > TOL @@ -3047,14 +3050,16 @@ def process(model = nil, argh = {}) tbd[:surfaces].each do |id, surface| next unless surface[:deratable] next unless surface.key?(:construction) + next unless surface.key?(:filmRSI) next if surface.key?(:u) - s = model.getSurfaceByName(id) - msg = "Skipping missing surface '#{id}' (#{mth})" - log(ERR, msg) if s.empty? - next if s.empty? + # s = model.getSurfaceByName(id) + # msg = "Skipping missing surface '#{id}' (#{mth})" + # log(ERR, msg) if s.empty? + # next if s.empty? - surface[:u] = 1.0 / rsi(surface[:construction], s.get.filmResistance) + # surface[:u] = 1.0 / rsi(surface[:construction], s.get.filmResistance) + surface[:u] = 1.0 / rsi(surface[:construction], surface[:filmRSI]) end json[:io][:edges] = [] diff --git a/lib/tbd/geo.rb b/lib/tbd/geo.rb index 00aeae4..4eab210 100644 --- a/lib/tbd/geo.rb +++ b/lib/tbd/geo.rb @@ -299,14 +299,16 @@ def properties(surface = nil, argh = {}) return invalid("#{nom} normal", mth, 0, ERR) unless n type = surface.surfaceType.downcase - facing = surface.outsideBoundaryCondition + facing = surface.outsideBoundaryCondition.downcase + interz = false setpts = setpoints(space) - if facing.downcase == "surface" - empty = surface.adjacentSurface.empty? - return invalid("#{nom}: adjacent surface", mth, 0, ERR) if empty + if facing == "surface" + adj = surface.adjacentSurface + return invalid("#{nom}: adjacent surface", mth, 0, ERR) if adj.empty? - facing = surface.adjacentSurface.get.nameString + facing = adj.get.nameString + interz = true end unless surface.construction.empty? @@ -315,8 +317,9 @@ def properties(surface = nil, argh = {}) unless lc.empty? lc = lc.get lyr = insulatingLayer(lc) + idx = lyr[:index] - if lyr[:index].is_a?(Integer) && lyr[:index].between?(0, lc.numLayers - 1) + if idx.is_a?(Integer) && idx.between?(0, lc.numLayers - 1) surf[:construction] = lc # index: ... of layer/material (to derate) within construction # ltype: either :massless (RSi) or :standard (k + d) @@ -358,8 +361,14 @@ def properties(surface = nil, argh = {}) surf[:story ] = story.get unless story.empty? surf[:n ] = n surf[:gross ] = surface.grossArea - surf[:filmRSI ] = surface.filmResistance surf[:spandrel ] = spandrel?(surface) + surf[:filmRSI ] = surface.filmResistance + + if interz + typ = :ceiling # interzone roof or ceiling + typ = :partition if surf[:type] == :wall + surf[:filmRSI] = TBD.filmResistances(typ, surface.tilt) + end surface.subSurfaces.sort_by { |s| s.nameString }.each do |s| next if poly(s).empty? @@ -508,7 +517,8 @@ def properties(surface = nil, argh = {}) end unless u.is_a?(Numeric) - r = rsi(c, surface.filmResistance) + # r = rsi(c, surface.filmResistance) + r = rsi(c, surf[:filmRSI]) if r < TOL log(ERR, "Skipping '#{id}': U-factor unavailable (#{mth})") @@ -831,7 +841,7 @@ def kiva(model = nil, walls = {}, floors = {}, edges = {}) edge[:surfaces].keys.each do |id| next unless floors.key?(id) - next unless floors[id][:boundary].downcase == "foundation" + next unless floors[id][:boundary] == "foundation" next if floors[id].key?(:kiva) # Initially set as slab-on-grade. Track 'exposed foundation perimeter'. @@ -845,7 +855,7 @@ def kiva(model = nil, walls = {}, floors = {}, edges = {}) edge[:surfaces].keys.each do |i| next if i == id next unless walls.key?(i) - next unless walls[i][:boundary].downcase == "foundation" + next unless walls[i][:boundary] == "foundation" next if walls[i].key?(:kiva) floors[id][:kiva ] = :basement @@ -857,7 +867,7 @@ def kiva(model = nil, walls = {}, floors = {}, edges = {}) edge[:surfaces].keys.each do |i| next if i == id next unless walls.key?(i) - next unless walls[i][:boundary].downcase == "outdoors" + next unless walls[i][:boundary] == "outdoors" floors[id][:exposed] += edge[:length] end @@ -872,7 +882,7 @@ def kiva(model = nil, walls = {}, floors = {}, edges = {}) e[:surfaces].keys.each do |ii| next if i == ii next unless walls.key?(ii) - next unless walls[ii][:boundary].downcase == "foundation" + next unless walls[ii][:boundary] == "foundation" next if walls[ii].key?(:kiva) floors[id][:kiva ] = :basement @@ -883,7 +893,7 @@ def kiva(model = nil, walls = {}, floors = {}, edges = {}) e[:surfaces].keys.each do |ii| next if i == ii next unless walls.key?(ii) - next unless walls[ii][:boundary].downcase == "outdoors" + next unless walls[ii][:boundary] == "outdoors" floors[id][:exposed] += e[:length] end diff --git a/lib/tbd/psi.rb b/lib/tbd/psi.rb index 7c37714..0f1d747 100644 --- a/lib/tbd/psi.rb +++ b/lib/tbd/psi.rb @@ -1555,7 +1555,7 @@ def process(model = nil, argh = {}) next unless surface[:conditioned] next if surface[:ground ] - unless surface[:boundary].downcase == "outdoors" + unless surface[:boundary] == "outdoors" next unless tbd[:surfaces].key?(surface[:boundary]) next if tbd[:surfaces][surface[:boundary]][:conditioned] end @@ -2202,7 +2202,7 @@ def process(model = nil, argh = {}) next if holes.key?(i) next if shades.key?(i) - facing = tbd[:surfaces][i][:boundary].downcase + facing = tbd[:surfaces][i][:boundary] next unless facing == "othersidecoefficients" s1 = edge[:surfaces][id] @@ -2971,6 +2971,7 @@ def process(model = nil, argh = {}) # 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?(:filmRSI) next unless surface.key?(:index) next unless surface.key?(:ltype) next unless surface.key?(:r) @@ -2995,7 +2996,8 @@ def process(model = nil, argh = {}) if m c.setLayer(index, m) c.setName("#{id} c tbd") - current_R = rsi(current_c, s.filmResistance) + # current_R = rsi(current_c, s.filmResistance) + current_R = rsi(current_c, surface[:filmRSI]) # In principle, the derated "ratio" could be calculated simply by # accessing a surface's uFactor. Yet air layers within constructions @@ -3035,7 +3037,8 @@ def process(model = nil, argh = {}) # Compute updated RSi value from layers. updated_c = s.construction.get.to_LayeredConstruction.get - updated_R = rsi(updated_c, s.filmResistance) + # updated_R = rsi(updated_c, s.filmResistance) + updated_R = rsi(updated_c, surface[:filmRSI]) ratio = -(current_R - updated_R) * 100 / current_R surface[:ratio] = ratio if ratio.abs > TOL @@ -3047,14 +3050,16 @@ def process(model = nil, argh = {}) tbd[:surfaces].each do |id, surface| next unless surface[:deratable] next unless surface.key?(:construction) + next unless surface.key?(:filmRSI) next if surface.key?(:u) - s = model.getSurfaceByName(id) - msg = "Skipping missing surface '#{id}' (#{mth})" - log(ERR, msg) if s.empty? - next if s.empty? + # s = model.getSurfaceByName(id) + # msg = "Skipping missing surface '#{id}' (#{mth})" + # log(ERR, msg) if s.empty? + # next if s.empty? - surface[:u] = 1.0 / rsi(surface[:construction], s.get.filmResistance) + # surface[:u] = 1.0 / rsi(surface[:construction], s.get.filmResistance) + surface[:u] = 1.0 / rsi(surface[:construction], surface[:filmRSI]) end json[:io][:edges] = [] diff --git a/spec/tbd_tests_spec.rb b/spec/tbd_tests_spec.rb index a32634d..c2f5884 100644 --- a/spec/tbd_tests_spec.rb +++ b/spec/tbd_tests_spec.rb @@ -336,7 +336,7 @@ # entry for "Entryway Wall 5" : "bad" fenestration perimeters, which # only derates the host wall itself surfaces.each do |id, surface| - next unless surface[:boundary].downcase == "outdoors" + next unless surface[:boundary] == "outdoors" expect(surface).to_not have_key(:ratio) unless id == name expect(surface[:heatloss]).to be_within(TOL).of(8.89) if id == name @@ -2659,6 +2659,7 @@ expect(surface).to be_a(Hash) expect(surface).to have_key(:conditioned) + expect(surface).to have_key(:filmRSI) expect(surface).to have_key(:deratable) expect(surface).to have_key(:construction) expect(surface).to have_key(:ground) @@ -2687,7 +2688,8 @@ expect(c.nameString).to include("c tbd") # TBD-derated a += surface[:net] - ua += 1 / TBD.rsi(c, s.filmResistance) * surface[:net] + # ua += 1 / TBD.rsi(c, s.filmResistance) * surface[:net] + ua += 1 / TBD.rsi(c, surface[:filmRSI]) * surface[:net] end expect(ua / a).to be_within(TOL).of(argh[:roof_ut]) @@ -2956,6 +2958,7 @@ surfaces.each do |nom, surface| expect(surface).to be_a(Hash) expect(surface).to have_key(:conditioned) + expect(surface).to have_key(:filmRSI) expect(surface).to have_key(:deratable) expect(surface).to have_key(:construction) expect(surface).to have_key(:ground) @@ -2988,7 +2991,8 @@ expect(c.nameString).to include("c tbd") # TBD-derated a += surface[:net] - ua += 1 / TBD.rsi(c, s.filmResistance) * surface[:net] + # ua += 1 / TBD.rsi(c, s.filmResistance) * surface[:net] + ua += 1 / TBD.rsi(c, surface[:filmRSI]) * surface[:net] end expect(ua / a).to be_within(TOL).of(argh[:roof_ut]) @@ -3219,7 +3223,7 @@ end surfaces.each do |id, surface| - next unless surface[:boundary].downcase == "outdoors" + next unless surface[:boundary] == "outdoors" next unless surface.key?(:ratio) expect(ids).to have_value(id) @@ -3768,7 +3772,7 @@ expect(surface[:ratio]).to be_within(0.2).of( -1.3) if id == ids[:i] # -7.3% expect(surface[:ratio]).to be_within(0.2).of( -1.5) if id == ids[:j] else - expect(surface[:boundary].downcase).to_not eq("outdoors") + expect(surface[:boundary]).to_not eq("outdoors") end end @@ -3843,7 +3847,7 @@ expect(surface[:ratio]).to be_within(0.2).of( -0.1) if id == ids[:j] # -1.3% # ! office walls: same results ... no parapet/roof else - expect(surface[:boundary].downcase).to_not eq("outdoors") + expect(surface[:boundary]).to_not eq("outdoors") end end @@ -3923,7 +3927,7 @@ expect(surface[:ratio]).to be_within(0.2).of( -0.1) if id == ids[:j] # Bulk Rear # ! office walls: same results ... no parapet/roof else - expect(surface[:boundary].downcase).to_not eq("outdoors") + expect(surface[:boundary]).to_not eq("outdoors") end end @@ -4011,7 +4015,7 @@ expect(surface[:ratio]).to be_within(0.2).of( -1.5) if id == ids[:j] # Bulk Rear # ! office walls: same results ... no parapet/roof else - expect(surface[:boundary].downcase).to_not eq("outdoors") + expect(surface[:boundary]).to_not eq("outdoors") end end end From ba85891c360932a0397800e84faab7751475d544 Mon Sep 17 00:00:00 2001 From: brgix Date: Thu, 9 Apr 2026 15:01:28 -0400 Subject: [PATCH 04/14] Revises uprating calcs --- lib/measures/tbd/measure.xml | 6 +- lib/measures/tbd/resources/ua.rb | 430 +++++++++++++++---------------- lib/tbd/ua.rb | 430 +++++++++++++++---------------- lib/tbd/version.rb | 2 +- spec/tbd_tests_spec.rb | 136 ++++++---- 5 files changed, 491 insertions(+), 513 deletions(-) diff --git a/lib/measures/tbd/measure.xml b/lib/measures/tbd/measure.xml index b9ebbfc..d831357 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 - 42594ae8-7b45-4a0b-aff8-d3fafee576d0 - 2026-04-02T19:46:02Z + d9c660a2-7a01-4b25-86a5-442265b71441 + 2026-04-09T17:40:54Z 99772807 TBDMeasure Thermal Bridging and Derating - TBD @@ -541,7 +541,7 @@ ua.rb rb resource - D3A2A391 + 47360A10 utils.rb diff --git a/lib/measures/tbd/resources/ua.rb b/lib/measures/tbd/resources/ua.rb index c4caad3..330fe27 100644 --- a/lib/measures/tbd/resources/ua.rb +++ b/lib/measures/tbd/resources/ua.rb @@ -25,103 +25,100 @@ module TBD # Calculates construction Uo (including surface film resistances) to meet Ut. # # @param model [OpenStudio::Model::Model] a model - # @param lc [OpenStudio::Model::LayeredConstruction] a layered construction # @param id [#to_s] layered construction identifier - # @param hloss [Numeric] heat loss from major thermal bridging, in W/K + # @param lc [OpenStudio::Model::LayeredConstruction] a layered construction + # @param area [Numeric] net surface area covered by layered construction # @param film [Numeric] target surface film resistance, in m2•K/W + # @param hloss [Numeric] heat loss from major thermal bridging, in W/K # @param ut [Numeric] target overall Ut for lc, in W/m2•K # - # @return [Hash] uo: lc Uo [W/m2•K] to meet Ut, m: uprated lc layer - # @return [Hash] uo: (nil), m: (nil) if invalid input (see logs) - def uo(model = nil, lc = nil, id = "", hloss = 0.0, film = 0.0, ut = 0.0) + # @return [Float] Uo [W/m2•K] required to meet Ut (see logs if 0) + def uo(id = "", lc = nil, area = 0, film = 0, hloss = 0, ut = 0) mth = "TBD::#{__callee__}" - res = { uo: nil, m: nil } - cl1 = OpenStudio::Model::Model - cl2 = OpenStudio::Model::LayeredConstruction - cl3 = Numeric - cl4 = String + cl1 = OpenStudio::Model::LayeredConstruction + cl2 = Numeric + cl3 = String id = trim(id) - return mismatch("model", model, cl1, mth, DBG, res) unless model.is_a?(cl1) - return mismatch("id" , id, cl4, mth, DBG, res) if id.empty? - return mismatch("lc" , lc, cl2, mth, DBG, res) unless lc.is_a?(cl2) - return mismatch("hloss", hloss, cl3, mth, DBG, res) unless hloss.is_a?(cl3) - return mismatch("film" , film, cl3, mth, DBG, res) unless film.is_a?(cl3) - return mismatch("Ut" , ut, cl3, mth, DBG, res) unless ut.is_a?(cl3) - - loss = 0.0 # residual heatloss (not assigned) [W/K] - area = lc.getNetArea - lyr = insulatingLayer(lc) + return mismatch("id" , id, cl3, mth, DBG, 0) if id.empty? + return mismatch("lc" , lc, cl1, mth, DBG, 0) unless lc.is_a?(cl1) + return mismatch("area" , area, cl2, mth, DBG, 0) unless area.is_a?(cl2) + return mismatch("film" , film, cl2, mth, DBG, 0) unless film.is_a?(cl2) + return mismatch("hloss", hloss, cl2, mth, DBG, 0) unless hloss.is_a?(cl2) + return mismatch("Ut" , ut, cl2, mth, DBG, 0) unless ut.is_a?(cl2) + + # Residual heatloss (not assigned) [W/K]. + model = lc.model + loss = 0 + lyr = insulatingLayer(lc) + + # Validate insulating layer. 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, 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 > 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 - ro = rsi(lc, film) # current construction Ro - new_r = lyr[:r] + (rt - ro) # new, un-derated layer RSi - new_u = 1 / new_r - - # Then, uprate (if possible) to counter expected thermal bridging effects. - 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, WRN, res) unless new_r > RMIN + return invalid("#{id} layer index", mth, 3, WRN, 0) unless lyr[:index] + return zero("#{id}: net area (m2)", mth, WRN, 0) unless area > TOL + return zero("#{id}: film RSI" , mth, WRN, 0) unless film > 0 + return zero("#{id}: heatloss" , mth, WRN, 0) unless hloss > TOL + return zero("#{id}: Ut" , mth, WRN, 0) unless ut > UMIN + return invalid("#{id}: Ut" , mth, 4, WRN, 0) unless ut < UMAX + + # Calculate initial layer RSi to initially meet Ut target. + rt = 1 / ut # target construction Rt + r0 = rsi(lc, film) # current construction R0 + r = lyr[:r] + rt - r0 # new, un-derated layer RSi + return zero("#{id}: layer RSI", mth, WRN, 0) unless r.abs > 0 + + # Uprate to counter heat loss from thermal bridging. + u = 1 / r + u -= hloss / area + return negative("#{id}: new Uo", mth, WRN, 0) if u < UMIN + + r = 1 / u if lyr[:type] == :massless m = lc.getLayer(lyr[:index]).to_MasslessOpaqueMaterial - return invalid("#{id} massless layer?", mth, 0, DBG, res) if m.empty? + return invalid("#{id} massless layer?", mth, 0, DBG, 0) 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 + r = RMIN if r < RMIN + loss = (u - 1 / r) * area if r < RMIN - unless m.setThermalResistance(new_r) - return invalid("Can't uprate #{id}: RSi#{new_r.round(2)}", mth, 0, DBG, res) + unless m.setThermalResistance(r) + return invalid("Can't uprate #{id}: RSi#{r.round(2)}", mth, 0, DBG, 0) end else m = lc.getLayer(lyr[:index]).to_StandardOpaqueMaterial - return invalid("#{id} standard layer?", mth, 0, DBG, res) if m.empty? + return invalid("#{id} standard layer?", mth, 0, DBG, 0) if m.empty? 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) + k = (d / r).clamp(KMIN, KMAX) + d = (k * r).clamp(DMIN, DMAX) - loss = (new_u - k / d) * area unless d / k > RMIN + loss = (u - k / d) * area if d / k < RMIN unless m.setThermalConductivity(k) - return invalid("Can't uprate #{id}: K#{k.round(3)}", mth, 0, DBG, res) + return invalid("Can't uprate #{id}: K#{k.round(3)}", mth, 0, DBG, 0) end unless m.setThickness(d) - return invalid("Can't uprate #{id}: #{(d*1000).to_i}mm", mth, 0, DBG, res) + return invalid("Can't uprate #{id}: #{(d*1000).to_i}mm", mth, 0, DBG, 0) end end - return invalid("Can't ID insulating layer", mth, 0, DBG, res) unless m + return invalid("Can't ID insulating layer", mth, 0, DBG, 0) 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, DBG, res) - end - - res[:uo] = uo - res[:m ] = m + h = format "%.3f", loss + log(WRN, "Can't set #{h} W/K to #{id} #{mth}") if loss > TOL - res + u end ## @@ -185,230 +182,211 @@ def uprate(model = nil, s = {}, argh = {}) groups[:roof ][:ut] = argh[:roof_ut ] groups[:floor][:ut] = argh[:floor_ut ] - groups[:wall ][:op] = trim(argh[:wall_option ]) - groups[:roof ][:op] = trim(argh[:roof_option ]) - groups[:floor][:op] = trim(argh[:floor_option ]) + groups[:wall ][:op] = trim(argh[:wall_option ]) + groups[:roof ][:op] = trim(argh[:roof_option ]) + groups[:floor][:op] = trim(argh[:floor_option]) + # Group and process walls, roofs and floors sequentially/independently. groups.each do |type, g| next unless g[:up] next unless g[:ut].is_a?(Numeric) next unless g[:ut] < UMAX - next if g[:ut] < 0 + next unless g[:ut] > UMIN + + typ = type + typ = :ceiling if typ == :roof - typ = type - typ = :ceiling if typ == :roof + # Collection of one or several constructions to uprate. coll = {} - area = 0 - film = 100000000000000 - lc = nil - id = "" op = g[:op].downcase - all = tout.include?(op) - if g[:op].empty? - log(WRN, "Construction (#{type}) to uprate? (#{mth})") - elsif all + if tout.include?(op) # uprate all constructions of same type, e.g. walls s.each do |nom, surface| - next unless surface.key?(:deratable ) - next unless surface.key?(:type ) + next unless surface.key?(:deratable) + next unless surface.key?(:type) next unless surface.key?(:construction) - next unless surface.key?(:filmRSI ) - next unless surface.key?(:index ) - next unless surface.key?(:ltype ) - next unless surface.key?(:r ) + next unless surface.key?(:filmRSI) + next unless surface.key?(:ltype) + next unless surface.key?(:r) + next unless surface.key?(:index) + next unless surface.key?(:net) next unless surface[:deratable ] next unless surface[:type ] == typ next unless surface[:construction].is_a?(cl3) next if surface[:index ].nil? - # Retain lowest surface film resistance (e.g. tilted surfaces). - c = surface[:construction] - i = c.nameString - aire = c.getNetArea - film = surface[:filmRSI] if surface[:filmRSI] < film - - # Retain construction covering largest area. The following conditional - # is reliable UNLESS linked to other deratable surface types e.g. both - # floors AND walls (see "elsif lc" corrections below). - if aire > area - lc = c - area = aire - id = i + # Collect constructions to uprate. + lc = surface[:construction] + id = lc.nameString + + # Track construction-specific parameters. + unless coll.key?(id) + coll[id] = {} + coll[id][:lc ] = lc + coll[id][:s ] = {} + coll[id][:hloss] = 0 + coll[id][:area ] = 0 + coll[id][:film ] = 0 + coll[id][:fA ] = 0 + coll[id][:uA ] = 0 + coll[id][:u0 ] = 0 end - coll[i] = { area: aire, lc: c, s: {} } unless coll.key?(i) - coll[i][:s][nom] = { a: surface[:net] } unless coll[i][:s].key?(nom) + coll[id][:idx] = surface[:index] unless coll[id].key?(:idx) + coll[id][:ltp] = surface[:ltype] unless coll[id].key?(:ltp) + + # Track surface-specific parameters. + unless coll[id][:s].key?(nom) + coll[id][:s][nom] = {} + coll[id][:s][nom][:a] = surface[:net] + coll[id][:s][nom][:f] = surface[:filmRSI] + coll[id][:s][nom][:h] = 0 + next unless surface.key?(:heatloss) + next unless surface[:heatloss].abs > TOL + + coll[id][:s][nom][:h] = surface[:heatloss] + end end else - id = g[:op] + id = op # single, user-selected construction lc = model.getConstructionByName(id) - log(WRN, "Construction '#{id}'? (#{mth})") if lc.empty? - next if lc.empty? + + if lc.empty? + log(WRN, "Construction '#{id}'? (#{mth})") + next + end lc = lc.get.to_LayeredConstruction - log(WRN, "'#{id}' layered construction? (#{mth})") if lc.empty? - next if lc.empty? - lc = lc.get - area = lc.getNetArea - coll[id] = { area: area, lc: lc, s: {} } + if lc.empty? + log(WRN, "'#{id}' layered construction? (#{mth})") + next + end + + coll[id] = {} + coll[id][:lc ] = lc + coll[id][:s ] = {} + coll[id][:idx ] = surface[:index] + coll[id][:ltp ] = surface[:ltype] + coll[id][:hloss] = 0 + coll[id][:area ] = 0 + coll[id][:film ] = 0 + coll[id][:fA ] = 0 + coll[id][:uA ] = 0 + coll[id][:u0 ] = 0 s.each do |nom, surface| - next unless surface.key?(:deratable ) - next unless surface.key?(:type ) + next unless surface.key?(:deratable) + next unless surface.key?(:type) next unless surface.key?(:construction) - next unless surface.key?(:filmRSI ) - next unless surface.key?(:index ) - next unless surface.key?(:ltype ) - next unless surface.key?(:r ) + next unless surface.key?(:filmRSI) + next unless surface.key?(:ltype) + next unless surface.key?(:r) + next unless surface.key?(:index) + next unless surface.key?(:net) next unless surface[:deratable ] next unless surface[:type ] == typ next unless surface[:construction].is_a?(cl3) + next unless surface[:construction].nameString == id next if surface[:index ].nil? - i = surface[:construction].nameString - next unless i == id - - # Retain lowest surface film resistance (e.g. tilted surfaces). - film = surface[:filmRSI] if surface[:filmRSI] < film - - coll[i][:s][nom] = { a: surface[:net] } unless coll[i][:s].key?(nom) + coll[id][:idx] = surface[:index] unless coll[id].key?(:idx) + coll[id][:ltp] = surface[:ltype] unless coll[id].key?(:ltp) + + # Track (for surfaces of targeted type): + # - net area + # - air film resistances + unless coll[id][:s].key?(nom) + coll[id][:s][nom] = {} + coll[id][:s][nom][:a] = surface[:net] + coll[id][:s][nom][:f] = surface[:filmRSI] + coll[id][:s][nom][:h] = 0 + next unless surface.key?(:heatloss) + next unless surface[:heatloss].abs > TOL + + coll[id][:s][nom][:h] = surface[:heatloss] + end end end if coll.empty? - log(WRN, "No #{type} construction to uprate - skipping (#{mth})") + log(WRN, "Unable to uprate #{type} construction - skipping (#{mth})") next - elsif lc - # Valid layered construction - good to uprate! - lyr = insulatingLayer(lc) - 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 - - 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. - # If not, assign new lc clone to non-targeted surfaces. - s.each do |nom, surface| - next unless surface.key?(:type ) - next unless surface.key?(:deratable ) - 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 coll.key?(id) - ok = false unless coll[id][:s].key?(nom) + else + coll.each do |id, col| + lc = col[:lc] + + # Ensure lc is exclusively linked to deratable surfaces of targeted + # type. If not, assign new lc clone to non-targeted surfaces. + s.each do |nom, surface| + next unless surface.key?(:deratable) + next unless surface.key?(:type) + next unless surface.key?(:construction) + next unless surface.key?(:filmRSI) + next unless surface.key?(:ltype) + next unless surface.key?(:r) + next unless surface.key?(:index) + next unless surface.key?(:net) + next unless surface[:deratable] + next unless surface[:construction].is_a?(cl3) + next unless surface[:construction] == lc + next if surface[:index ].nil? + next if surface[:type ] == typ + next if coll[id][:s].key?(nom) - unless ok log(WRN, "Cloning '#{nom}' construction - not '#{id}' (#{mth})") - sss = model.getSurfaceByName(nom) - next if sss.empty? + srf = model.getSurfaceByName(nom) + next if srf.empty? - sss = sss.get + srf = srf.get cloned = lc.clone(model).to_LayeredConstruction.get cloned.setName("#{nom} - cloned") - sss.setConstruction(cloned) + srf.setConstruction(cloned) surface[:construction] = cloned - coll[id][:s].delete(nom) end end - hloss = 0 # sum of applicable psi+khi-related losses [W/K] - - # Tally applicable psi+khi losses. Possible construction reassignment. - coll.each do |i, col| - col[:s].keys.each do |nom| - next unless s.key?(nom) - next unless s[nom].key?(:construction) - next unless s[nom].key?(:index) - next unless s[nom].key?(:ltype) - next unless s[nom].key?(:r) - - # Tally applicable psi+khi. - hloss += s[nom][:heatloss ] if s[nom].key?(:heatloss) - next if s[nom][:construction] == lc - - # Reassign construction unless referencing lc. - sss = model.getSurfaceByName(nom) - next if sss.empty? - - sss = sss.get + coll.each do |id, col| + col[:s].values.each do |item| + col[:hloss] += item[:h] + col[:area ] += item[:a] + col[:fA ] += item[:a] / item[:f] + end - if sss.isConstructionDefaulted - set = defaultConstructionSet(sss) # building? story? + # Area-weighted surface air film resistances. + col[:film] = 1 / ( col[:fA] / col[:area] ) - if set.nil? - sss.setConstruction(lc) - else - constructions = set.defaultExteriorSurfaceConstructions + # Fetch required, uprated Uo. + u = uo(id, col[:lc], col[:area], col[:film], col[:hloss], g[:ut]) - 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) - end - - s[nom][:construction] = lc # reset TBD attributes - s[nom][:index ] = lyr[:index] - s[nom][:ltype ] = lyr[:type ] - s[nom][:r ] = lyr[:r ] # temporary + if u < UMIN + log(WRN, "Unable to completely uprate '#{id}' (#{mth})") + u = UMIN end - end - # Merge to ensure a single entry for coll Hash. - coll.each do |i, col| - next if i == id - - col[:s].each do |nom, sss| - coll[id][:s][nom] = sss unless coll[id][:s].key?(nom) - end - end + col[:u ] = u + col[:uA] = u * col[:area] - coll.delete_if { |i, _| i != id } + # Recoup uprated construction and insulating layer. + lc = col[:lc] + lyr = insulatingLayer(lc) - unless coll.size == 1 - log(DBG, "Collection == 1? for '#{id}' (#{mth})") - next - end - - coll[id][:area] = lc.getNetArea - res = uo(model, lc, id, hloss, film, g[:ut]) + # Reset surface :r (uprated RSi of insulation, before derating). + col[:s].keys.each do |nom| + next unless s.key?(nom) + next unless s[nom].key?(:r) - unless res[:uo] && res[:m] - log(WRN, "Unable to uprate '#{id}' (#{mth})") - next + s[nom][:r] = lyr[:r] + end end - lyr = insulatingLayer(lc) - - # Loop through coll :s, and reset :r - likely modified by uo(). - coll.values.first[:s].keys.each do |nom| - next unless s.key?(nom) - next unless s[nom].key?(:index) - next unless s[nom].key?(:ltype) - next unless s[nom].key?(:r ) - next unless s[nom][:index] == lyr[:index] - next unless s[nom][:ltype] == lyr[:type ] + # Store UA-averaged, upgraded Uo-factor per type. + area = coll.values.sum { |col| col[:area] } + uA = coll.values.sum { |col| col[:uA ] } - s[nom][:r] = lyr[:r] # uprated insulating RSi factor, before derating - end - - argh[:wall_uo ] = res[:uo] if typ == :wall - argh[:roof_uo ] = res[:uo] if typ == :ceiling - argh[:floor_uo] = res[:uo] if typ == :floor - else - log(WRN, "Nilled construction to uprate - (#{mth})") - return false + argh[:wall_uo ] = uA / area if typ == :wall + argh[:roof_uo ] = uA / area if typ == :ceiling + argh[:floor_uo] = uA / area if typ == :floor end end @@ -1000,7 +978,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.5.2" + report << "* TBD : v3.6.0" report << "* date : #{ua[:date]}" if lang == :en diff --git a/lib/tbd/ua.rb b/lib/tbd/ua.rb index c4caad3..330fe27 100644 --- a/lib/tbd/ua.rb +++ b/lib/tbd/ua.rb @@ -25,103 +25,100 @@ module TBD # Calculates construction Uo (including surface film resistances) to meet Ut. # # @param model [OpenStudio::Model::Model] a model - # @param lc [OpenStudio::Model::LayeredConstruction] a layered construction # @param id [#to_s] layered construction identifier - # @param hloss [Numeric] heat loss from major thermal bridging, in W/K + # @param lc [OpenStudio::Model::LayeredConstruction] a layered construction + # @param area [Numeric] net surface area covered by layered construction # @param film [Numeric] target surface film resistance, in m2•K/W + # @param hloss [Numeric] heat loss from major thermal bridging, in W/K # @param ut [Numeric] target overall Ut for lc, in W/m2•K # - # @return [Hash] uo: lc Uo [W/m2•K] to meet Ut, m: uprated lc layer - # @return [Hash] uo: (nil), m: (nil) if invalid input (see logs) - def uo(model = nil, lc = nil, id = "", hloss = 0.0, film = 0.0, ut = 0.0) + # @return [Float] Uo [W/m2•K] required to meet Ut (see logs if 0) + def uo(id = "", lc = nil, area = 0, film = 0, hloss = 0, ut = 0) mth = "TBD::#{__callee__}" - res = { uo: nil, m: nil } - cl1 = OpenStudio::Model::Model - cl2 = OpenStudio::Model::LayeredConstruction - cl3 = Numeric - cl4 = String + cl1 = OpenStudio::Model::LayeredConstruction + cl2 = Numeric + cl3 = String id = trim(id) - return mismatch("model", model, cl1, mth, DBG, res) unless model.is_a?(cl1) - return mismatch("id" , id, cl4, mth, DBG, res) if id.empty? - return mismatch("lc" , lc, cl2, mth, DBG, res) unless lc.is_a?(cl2) - return mismatch("hloss", hloss, cl3, mth, DBG, res) unless hloss.is_a?(cl3) - return mismatch("film" , film, cl3, mth, DBG, res) unless film.is_a?(cl3) - return mismatch("Ut" , ut, cl3, mth, DBG, res) unless ut.is_a?(cl3) - - loss = 0.0 # residual heatloss (not assigned) [W/K] - area = lc.getNetArea - lyr = insulatingLayer(lc) + return mismatch("id" , id, cl3, mth, DBG, 0) if id.empty? + return mismatch("lc" , lc, cl1, mth, DBG, 0) unless lc.is_a?(cl1) + return mismatch("area" , area, cl2, mth, DBG, 0) unless area.is_a?(cl2) + return mismatch("film" , film, cl2, mth, DBG, 0) unless film.is_a?(cl2) + return mismatch("hloss", hloss, cl2, mth, DBG, 0) unless hloss.is_a?(cl2) + return mismatch("Ut" , ut, cl2, mth, DBG, 0) unless ut.is_a?(cl2) + + # Residual heatloss (not assigned) [W/K]. + model = lc.model + loss = 0 + lyr = insulatingLayer(lc) + + # Validate insulating layer. 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, 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 > 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 - ro = rsi(lc, film) # current construction Ro - new_r = lyr[:r] + (rt - ro) # new, un-derated layer RSi - new_u = 1 / new_r - - # Then, uprate (if possible) to counter expected thermal bridging effects. - 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, WRN, res) unless new_r > RMIN + return invalid("#{id} layer index", mth, 3, WRN, 0) unless lyr[:index] + return zero("#{id}: net area (m2)", mth, WRN, 0) unless area > TOL + return zero("#{id}: film RSI" , mth, WRN, 0) unless film > 0 + return zero("#{id}: heatloss" , mth, WRN, 0) unless hloss > TOL + return zero("#{id}: Ut" , mth, WRN, 0) unless ut > UMIN + return invalid("#{id}: Ut" , mth, 4, WRN, 0) unless ut < UMAX + + # Calculate initial layer RSi to initially meet Ut target. + rt = 1 / ut # target construction Rt + r0 = rsi(lc, film) # current construction R0 + r = lyr[:r] + rt - r0 # new, un-derated layer RSi + return zero("#{id}: layer RSI", mth, WRN, 0) unless r.abs > 0 + + # Uprate to counter heat loss from thermal bridging. + u = 1 / r + u -= hloss / area + return negative("#{id}: new Uo", mth, WRN, 0) if u < UMIN + + r = 1 / u if lyr[:type] == :massless m = lc.getLayer(lyr[:index]).to_MasslessOpaqueMaterial - return invalid("#{id} massless layer?", mth, 0, DBG, res) if m.empty? + return invalid("#{id} massless layer?", mth, 0, DBG, 0) 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 + r = RMIN if r < RMIN + loss = (u - 1 / r) * area if r < RMIN - unless m.setThermalResistance(new_r) - return invalid("Can't uprate #{id}: RSi#{new_r.round(2)}", mth, 0, DBG, res) + unless m.setThermalResistance(r) + return invalid("Can't uprate #{id}: RSi#{r.round(2)}", mth, 0, DBG, 0) end else m = lc.getLayer(lyr[:index]).to_StandardOpaqueMaterial - return invalid("#{id} standard layer?", mth, 0, DBG, res) if m.empty? + return invalid("#{id} standard layer?", mth, 0, DBG, 0) if m.empty? 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) + k = (d / r).clamp(KMIN, KMAX) + d = (k * r).clamp(DMIN, DMAX) - loss = (new_u - k / d) * area unless d / k > RMIN + loss = (u - k / d) * area if d / k < RMIN unless m.setThermalConductivity(k) - return invalid("Can't uprate #{id}: K#{k.round(3)}", mth, 0, DBG, res) + return invalid("Can't uprate #{id}: K#{k.round(3)}", mth, 0, DBG, 0) end unless m.setThickness(d) - return invalid("Can't uprate #{id}: #{(d*1000).to_i}mm", mth, 0, DBG, res) + return invalid("Can't uprate #{id}: #{(d*1000).to_i}mm", mth, 0, DBG, 0) end end - return invalid("Can't ID insulating layer", mth, 0, DBG, res) unless m + return invalid("Can't ID insulating layer", mth, 0, DBG, 0) 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, DBG, res) - end - - res[:uo] = uo - res[:m ] = m + h = format "%.3f", loss + log(WRN, "Can't set #{h} W/K to #{id} #{mth}") if loss > TOL - res + u end ## @@ -185,230 +182,211 @@ def uprate(model = nil, s = {}, argh = {}) groups[:roof ][:ut] = argh[:roof_ut ] groups[:floor][:ut] = argh[:floor_ut ] - groups[:wall ][:op] = trim(argh[:wall_option ]) - groups[:roof ][:op] = trim(argh[:roof_option ]) - groups[:floor][:op] = trim(argh[:floor_option ]) + groups[:wall ][:op] = trim(argh[:wall_option ]) + groups[:roof ][:op] = trim(argh[:roof_option ]) + groups[:floor][:op] = trim(argh[:floor_option]) + # Group and process walls, roofs and floors sequentially/independently. groups.each do |type, g| next unless g[:up] next unless g[:ut].is_a?(Numeric) next unless g[:ut] < UMAX - next if g[:ut] < 0 + next unless g[:ut] > UMIN + + typ = type + typ = :ceiling if typ == :roof - typ = type - typ = :ceiling if typ == :roof + # Collection of one or several constructions to uprate. coll = {} - area = 0 - film = 100000000000000 - lc = nil - id = "" op = g[:op].downcase - all = tout.include?(op) - if g[:op].empty? - log(WRN, "Construction (#{type}) to uprate? (#{mth})") - elsif all + if tout.include?(op) # uprate all constructions of same type, e.g. walls s.each do |nom, surface| - next unless surface.key?(:deratable ) - next unless surface.key?(:type ) + next unless surface.key?(:deratable) + next unless surface.key?(:type) next unless surface.key?(:construction) - next unless surface.key?(:filmRSI ) - next unless surface.key?(:index ) - next unless surface.key?(:ltype ) - next unless surface.key?(:r ) + next unless surface.key?(:filmRSI) + next unless surface.key?(:ltype) + next unless surface.key?(:r) + next unless surface.key?(:index) + next unless surface.key?(:net) next unless surface[:deratable ] next unless surface[:type ] == typ next unless surface[:construction].is_a?(cl3) next if surface[:index ].nil? - # Retain lowest surface film resistance (e.g. tilted surfaces). - c = surface[:construction] - i = c.nameString - aire = c.getNetArea - film = surface[:filmRSI] if surface[:filmRSI] < film - - # Retain construction covering largest area. The following conditional - # is reliable UNLESS linked to other deratable surface types e.g. both - # floors AND walls (see "elsif lc" corrections below). - if aire > area - lc = c - area = aire - id = i + # Collect constructions to uprate. + lc = surface[:construction] + id = lc.nameString + + # Track construction-specific parameters. + unless coll.key?(id) + coll[id] = {} + coll[id][:lc ] = lc + coll[id][:s ] = {} + coll[id][:hloss] = 0 + coll[id][:area ] = 0 + coll[id][:film ] = 0 + coll[id][:fA ] = 0 + coll[id][:uA ] = 0 + coll[id][:u0 ] = 0 end - coll[i] = { area: aire, lc: c, s: {} } unless coll.key?(i) - coll[i][:s][nom] = { a: surface[:net] } unless coll[i][:s].key?(nom) + coll[id][:idx] = surface[:index] unless coll[id].key?(:idx) + coll[id][:ltp] = surface[:ltype] unless coll[id].key?(:ltp) + + # Track surface-specific parameters. + unless coll[id][:s].key?(nom) + coll[id][:s][nom] = {} + coll[id][:s][nom][:a] = surface[:net] + coll[id][:s][nom][:f] = surface[:filmRSI] + coll[id][:s][nom][:h] = 0 + next unless surface.key?(:heatloss) + next unless surface[:heatloss].abs > TOL + + coll[id][:s][nom][:h] = surface[:heatloss] + end end else - id = g[:op] + id = op # single, user-selected construction lc = model.getConstructionByName(id) - log(WRN, "Construction '#{id}'? (#{mth})") if lc.empty? - next if lc.empty? + + if lc.empty? + log(WRN, "Construction '#{id}'? (#{mth})") + next + end lc = lc.get.to_LayeredConstruction - log(WRN, "'#{id}' layered construction? (#{mth})") if lc.empty? - next if lc.empty? - lc = lc.get - area = lc.getNetArea - coll[id] = { area: area, lc: lc, s: {} } + if lc.empty? + log(WRN, "'#{id}' layered construction? (#{mth})") + next + end + + coll[id] = {} + coll[id][:lc ] = lc + coll[id][:s ] = {} + coll[id][:idx ] = surface[:index] + coll[id][:ltp ] = surface[:ltype] + coll[id][:hloss] = 0 + coll[id][:area ] = 0 + coll[id][:film ] = 0 + coll[id][:fA ] = 0 + coll[id][:uA ] = 0 + coll[id][:u0 ] = 0 s.each do |nom, surface| - next unless surface.key?(:deratable ) - next unless surface.key?(:type ) + next unless surface.key?(:deratable) + next unless surface.key?(:type) next unless surface.key?(:construction) - next unless surface.key?(:filmRSI ) - next unless surface.key?(:index ) - next unless surface.key?(:ltype ) - next unless surface.key?(:r ) + next unless surface.key?(:filmRSI) + next unless surface.key?(:ltype) + next unless surface.key?(:r) + next unless surface.key?(:index) + next unless surface.key?(:net) next unless surface[:deratable ] next unless surface[:type ] == typ next unless surface[:construction].is_a?(cl3) + next unless surface[:construction].nameString == id next if surface[:index ].nil? - i = surface[:construction].nameString - next unless i == id - - # Retain lowest surface film resistance (e.g. tilted surfaces). - film = surface[:filmRSI] if surface[:filmRSI] < film - - coll[i][:s][nom] = { a: surface[:net] } unless coll[i][:s].key?(nom) + coll[id][:idx] = surface[:index] unless coll[id].key?(:idx) + coll[id][:ltp] = surface[:ltype] unless coll[id].key?(:ltp) + + # Track (for surfaces of targeted type): + # - net area + # - air film resistances + unless coll[id][:s].key?(nom) + coll[id][:s][nom] = {} + coll[id][:s][nom][:a] = surface[:net] + coll[id][:s][nom][:f] = surface[:filmRSI] + coll[id][:s][nom][:h] = 0 + next unless surface.key?(:heatloss) + next unless surface[:heatloss].abs > TOL + + coll[id][:s][nom][:h] = surface[:heatloss] + end end end if coll.empty? - log(WRN, "No #{type} construction to uprate - skipping (#{mth})") + log(WRN, "Unable to uprate #{type} construction - skipping (#{mth})") next - elsif lc - # Valid layered construction - good to uprate! - lyr = insulatingLayer(lc) - 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 - - 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. - # If not, assign new lc clone to non-targeted surfaces. - s.each do |nom, surface| - next unless surface.key?(:type ) - next unless surface.key?(:deratable ) - 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 coll.key?(id) - ok = false unless coll[id][:s].key?(nom) + else + coll.each do |id, col| + lc = col[:lc] + + # Ensure lc is exclusively linked to deratable surfaces of targeted + # type. If not, assign new lc clone to non-targeted surfaces. + s.each do |nom, surface| + next unless surface.key?(:deratable) + next unless surface.key?(:type) + next unless surface.key?(:construction) + next unless surface.key?(:filmRSI) + next unless surface.key?(:ltype) + next unless surface.key?(:r) + next unless surface.key?(:index) + next unless surface.key?(:net) + next unless surface[:deratable] + next unless surface[:construction].is_a?(cl3) + next unless surface[:construction] == lc + next if surface[:index ].nil? + next if surface[:type ] == typ + next if coll[id][:s].key?(nom) - unless ok log(WRN, "Cloning '#{nom}' construction - not '#{id}' (#{mth})") - sss = model.getSurfaceByName(nom) - next if sss.empty? + srf = model.getSurfaceByName(nom) + next if srf.empty? - sss = sss.get + srf = srf.get cloned = lc.clone(model).to_LayeredConstruction.get cloned.setName("#{nom} - cloned") - sss.setConstruction(cloned) + srf.setConstruction(cloned) surface[:construction] = cloned - coll[id][:s].delete(nom) end end - hloss = 0 # sum of applicable psi+khi-related losses [W/K] - - # Tally applicable psi+khi losses. Possible construction reassignment. - coll.each do |i, col| - col[:s].keys.each do |nom| - next unless s.key?(nom) - next unless s[nom].key?(:construction) - next unless s[nom].key?(:index) - next unless s[nom].key?(:ltype) - next unless s[nom].key?(:r) - - # Tally applicable psi+khi. - hloss += s[nom][:heatloss ] if s[nom].key?(:heatloss) - next if s[nom][:construction] == lc - - # Reassign construction unless referencing lc. - sss = model.getSurfaceByName(nom) - next if sss.empty? - - sss = sss.get + coll.each do |id, col| + col[:s].values.each do |item| + col[:hloss] += item[:h] + col[:area ] += item[:a] + col[:fA ] += item[:a] / item[:f] + end - if sss.isConstructionDefaulted - set = defaultConstructionSet(sss) # building? story? + # Area-weighted surface air film resistances. + col[:film] = 1 / ( col[:fA] / col[:area] ) - if set.nil? - sss.setConstruction(lc) - else - constructions = set.defaultExteriorSurfaceConstructions + # Fetch required, uprated Uo. + u = uo(id, col[:lc], col[:area], col[:film], col[:hloss], g[:ut]) - 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) - end - - s[nom][:construction] = lc # reset TBD attributes - s[nom][:index ] = lyr[:index] - s[nom][:ltype ] = lyr[:type ] - s[nom][:r ] = lyr[:r ] # temporary + if u < UMIN + log(WRN, "Unable to completely uprate '#{id}' (#{mth})") + u = UMIN end - end - # Merge to ensure a single entry for coll Hash. - coll.each do |i, col| - next if i == id - - col[:s].each do |nom, sss| - coll[id][:s][nom] = sss unless coll[id][:s].key?(nom) - end - end + col[:u ] = u + col[:uA] = u * col[:area] - coll.delete_if { |i, _| i != id } + # Recoup uprated construction and insulating layer. + lc = col[:lc] + lyr = insulatingLayer(lc) - unless coll.size == 1 - log(DBG, "Collection == 1? for '#{id}' (#{mth})") - next - end - - coll[id][:area] = lc.getNetArea - res = uo(model, lc, id, hloss, film, g[:ut]) + # Reset surface :r (uprated RSi of insulation, before derating). + col[:s].keys.each do |nom| + next unless s.key?(nom) + next unless s[nom].key?(:r) - unless res[:uo] && res[:m] - log(WRN, "Unable to uprate '#{id}' (#{mth})") - next + s[nom][:r] = lyr[:r] + end end - lyr = insulatingLayer(lc) - - # Loop through coll :s, and reset :r - likely modified by uo(). - coll.values.first[:s].keys.each do |nom| - next unless s.key?(nom) - next unless s[nom].key?(:index) - next unless s[nom].key?(:ltype) - next unless s[nom].key?(:r ) - next unless s[nom][:index] == lyr[:index] - next unless s[nom][:ltype] == lyr[:type ] + # Store UA-averaged, upgraded Uo-factor per type. + area = coll.values.sum { |col| col[:area] } + uA = coll.values.sum { |col| col[:uA ] } - s[nom][:r] = lyr[:r] # uprated insulating RSi factor, before derating - end - - argh[:wall_uo ] = res[:uo] if typ == :wall - argh[:roof_uo ] = res[:uo] if typ == :ceiling - argh[:floor_uo] = res[:uo] if typ == :floor - else - log(WRN, "Nilled construction to uprate - (#{mth})") - return false + argh[:wall_uo ] = uA / area if typ == :wall + argh[:roof_uo ] = uA / area if typ == :ceiling + argh[:floor_uo] = uA / area if typ == :floor end end @@ -1000,7 +978,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.5.2" + report << "* TBD : v3.6.0" report << "* date : #{ua[:date]}" if lang == :en diff --git a/lib/tbd/version.rb b/lib/tbd/version.rb index 43e03c2..3f9ebb2 100644 --- a/lib/tbd/version.rb +++ b/lib/tbd/version.rb @@ -21,5 +21,5 @@ # SOFTWARE. module TBD - VERSION = "3.5.3".freeze + VERSION = "3.6.0".freeze end diff --git a/spec/tbd_tests_spec.rb b/spec/tbd_tests_spec.rb index c2f5884..2e91d62 100644 --- a/spec/tbd_tests_spec.rb +++ b/spec/tbd_tests_spec.rb @@ -1610,14 +1610,13 @@ insulation = construction.layers[2].to_StandardOpaqueMaterial expect(insulation).to_not be_empty insulation = insulation.get - expect(insulation.thickness).to be_within(0.0001).of(0.0794) - expect(insulation.thermalConductivity).to be_within(0.0001).of(0.0432) + expect(insulation.thickness.round(4)).to eq(0.0794) + expect(insulation.thermalConductivity.round(4)).to eq(0.0432) original_r = insulation.thickness / insulation.thermalConductivity - expect(original_r).to be_within(TOL).of(1.8380) + expect(original_r.round(4)).to eq(1.8380) argh = { option: "efficient (BETBG)" } # all PSI-factors @ 0.2 W/K•m - - json = TBD.process(model, argh) + json = TBD.process(model, argh) expect(json).to be_a(Hash) expect(json).to have_key(:io) expect(json).to have_key(:surfaces) @@ -1731,14 +1730,19 @@ surfaces = json[:surfaces] 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") - expect(TBD.logs.last[ :message]).to include("Unable to uprate") - - expect(argh).to_not have_key(:wall_uo) - expect(argh).to have_key(:roof_uo) + expect(TBD.logs.first[:message]).to include("Negative ") + expect(TBD.logs.first[:message]).to include(" new Uo' (TBD::uo)") + expect(TBD.logs.last[:message]).to include("Unable to completely uprate ") + expect(argh).to have_key(:wall_uo) + expect(argh).to have_key(:roof_uo) + expect(argh[:wall_uo]).to_not be_nil expect(argh[:roof_uo]).to_not be_nil - expect(argh[:roof_uo]).to be_within(TOL).of(0.118) # RSi 8.47 (R48) + + # Although the roof construction is correctly uprated, it is not possible to + # completely uprate the wall construction. It is therefore capped at the + # minimum allowed Uo-factor, or ~9 ft of XPS insulation. + expect(argh[:wall_uo].round(3)).to eq(0.010) # RSi 100.00 (R568) + expect(argh[:roof_uo].round(3)).to eq(0.121) # RSi 8.26 ( R47) # -- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- -- # # Final attempt, with PSI-factors of 0.09 W/K per linear metre (JSON file). @@ -1759,52 +1763,70 @@ argh[:wall_ut ] = 0.210 # NECB CZ7 2017 (RSi 4.76 / R27) argh[:roof_ut ] = 0.138 # NECB CZ7 2017 (RSi 7.25 / R41) - json = TBD.process(model, argh) + json = TBD.process(model, argh) expect(json).to be_a(Hash) expect(json).to have_key(:io) expect(json).to have_key(:surfaces) - io = json[:io ] - surfaces = json[:surfaces] + io = json[:io ] + surfaces = json[:surfaces] expect(TBD.status).to be_zero expect(argh).to have_key(:wall_uo) expect(argh).to have_key(:roof_uo) expect(argh[:wall_uo]).to_not be_nil expect(argh[:roof_uo]).to_not be_nil - expect(argh[:wall_uo]).to be_within(TOL).of(0.086) # RSi 11.63 (R66) - expect(argh[:roof_uo]).to be_within(TOL).of(0.129) # RSi 7.75 (R44) + expect(argh[:wall_uo].round(3)).to eq(0.089) # RSi 11.24 (R64) + expect(argh[:roof_uo].round(3)).to eq(0.132) # RSi 7.58 (R43) + + uA = 0 + m2 = 0 model.getSurfaces.each do |s| + id = s.nameString next unless s.surfaceType == "Wall" next unless s.outsideBoundaryCondition == "Outdoors" - walls << s.nameString + walls << id + + expect(s.isConstructionDefaulted).to be false c = s.construction expect(c).to_not be_empty c = c.get.to_LayeredConstruction expect(c).to_not be_empty c = c.get - expect(c.nameString).to include(" c tbd") expect(c.layers.size).to eq(4) + r = TBD.rsi(c, TBD.filmResistances(:wall)) + expect(r.round(3)).to eq(4.805) if id == "Surface 20" || id == "Surface 8" + expect(r.round(3)).to eq(4.679) if id == "Surface 14" || id == "Surface 2" + m2 += s.netArea + uA += s.netArea / r + insul = c.layers[2].to_StandardOpaqueMaterial expect(insul).to_not be_empty insul = insul.get expect(insul.nameString).to include(" uprated m tbd") - 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) + k = insul.thermalConductivity + expect(k.round(3)).to eq(0.025) if id == "Surface 20" || id == "Surface 8" + expect(k.round(3)).to eq(0.026) if id == "Surface 14" || id == "Surface 2" + expect(insul.thickness.round(4)).to eq(0.1120) end + expect(m2.round(2)).to eq(273.60) + expect(uA.round(2)).to eq(57.45) + + # Reach NECB required Ut for walls? + ut = uA/m2 + expect(ut.round(3)).to eq(argh[:wall_ut].round(3)) # 0.210 + walls.each do |wall| expect(surfaces).to have_key(wall) expect(surfaces[wall]).to have_key(:r) # uprated, non-derated layer Rsi expect(surfaces[wall]).to have_key(:u) # uprated, non-derated assembly - expect(surfaces[wall][:r]).to be_within(0.001).of(11.205) # R64 - expect(surfaces[wall][:u]).to be_within(0.001).of( 0.086) # R66 + expect(surfaces[wall][:r].round(3)).to eq(11.205) # R64 + expect(surfaces[wall][:u].round(3)).to eq( 0.086) # R66 end # -- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- -- # @@ -1863,7 +1885,14 @@ argh[:wall_ut ] = 0.210 # NECB CZ7 2017 (RSi 4.76 / R41) TBD.process(model, argh) + expect(TBD.warn?).to be true + expect(TBD.logs.size).to eq(2) + expect(TBD.logs.first[:message]).to include("Negative ") + expect(TBD.logs.first[:message]).to include(" new Uo' (TBD::uo)") + expect(TBD.logs.last[:message]).to include("Unable to completely uprate ") + expect(argh).to_not have_key(:roof_uo) + expect(argh).to have_key(:wall_uo) # OpenStudio prior to v3.5.X had a 3m maximum layer thickness, reflecting a # previous v8.8 EnergyPlus constraint. TBD caught such cases when uprating @@ -1876,8 +1905,6 @@ # 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) nb = 0 @@ -1904,7 +1931,7 @@ insul = insul.to_StandardOpaqueMaterial expect(insul).to_not be_empty insul = insul.get - expect(insul.thickness).to be_within(TOL).of(1.00) + expect(insul.thickness.round(3)).to eq(0.079) nb += 1 end @@ -2068,8 +2095,7 @@ expect(TBD.status).to be_zero argh = { option: "code (Quebec)" } - - json = TBD.process(model, argh) + json = TBD.process(model, argh) expect(TBD.status).to be_zero expect(json).to be_a(Hash) expect(json).to have_key(:io) @@ -2112,8 +2138,7 @@ expect(TBD.status).to be_zero argh = { option: "code (Quebec)" } - - json = TBD.process(model, argh) + json = TBD.process(model, argh) expect(json).to be_a(Hash) expect(json).to have_key(:io) expect(json).to have_key(:surfaces) @@ -2174,7 +2199,6 @@ argh = { option: "code (Quebec)" } json = TBD.process(model, argh) - puts TBD.logs unless TBD.logs.empty? expect(TBD.status).to be_zero expect(json).to be_a(Hash) expect(json).to have_key(:io) @@ -2200,7 +2224,6 @@ 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)) @@ -2213,7 +2236,14 @@ 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(TBD.warn?).to be true + expect(TBD.logs.size).to eq(4) + expect(TBD.logs[0][:message]).to include("Negative ") + expect(TBD.logs[2][:message]).to include("Negative ") + expect(TBD.logs[0][:message]).to include(" new Uo' (TBD::uo)") + expect(TBD.logs[2][:message]).to include(" new Uo' (TBD::uo)") + expect(TBD.logs[1][:message]).to include("Unable to completely uprate ") + expect(TBD.logs[3][:message]).to include("Unable to completely uprate ") expect(json).to be_a(Hash) expect(json).to have_key(:io) expect(json).to have_key(:surfaces) @@ -2227,6 +2257,8 @@ file = File.join(__dir__, "files/osms/out/office_attic_sky.osm") model.save(file, true) + TBD.clean! + # -- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- -- # # 5Zone_2 test case (as INDIRECTLYCONDITIONED plenum). plenum_walls = [] @@ -2254,11 +2286,11 @@ expect(TBD.unconditioned?(plnum)).to be true expect(TBD.setpoints(plnum)[:heating]).to be_nil expect(TBD.setpoints(plnum)[:cooling]).to be_nil + puts TBD.logs expect(TBD.status).to be_zero - argh = { option: "uncompliant (Quebec)" } - - json = TBD.process(model, argh) + argh = { option: "uncompliant (Quebec)" } + json = TBD.process(model, argh) expect(json).to be_a(Hash) expect(json).to have_key(:io) expect(json).to have_key(:surfaces) @@ -2316,8 +2348,7 @@ model.save(file, true) argh = { option: "uncompliant (Quebec)" } - - json = TBD.process(model, argh) + json = TBD.process(model, argh) expect(json).to be_a(Hash) expect(json).to have_key(:io) expect(json).to have_key(:surfaces) @@ -2462,9 +2493,8 @@ expect(heated).to be false expect(cooled).to be false - argh = { option: "code (Quebec)" } - - json = TBD.process(model, argh) + argh = { option: "code (Quebec)" } + json = TBD.process(model, argh) expect(json).to be_a(Hash) expect(json).to have_key(:io) expect(json).to have_key(:surfaces) @@ -2688,7 +2718,6 @@ expect(c.nameString).to include("c tbd") # TBD-derated a += surface[:net] - # ua += 1 / TBD.rsi(c, s.filmResistance) * surface[:net] ua += 1 / TBD.rsi(c, surface[:filmRSI]) * surface[:net] end @@ -2785,8 +2814,7 @@ expect(TBD.plenum?(attic)).to be true # works ... argh = { option: "code (Quebec)" } - - json = TBD.process(model, argh) + json = TBD.process(model, argh) expect(json ).to be_a(Hash) expect(json).to have_key(:io) expect(json).to have_key(:surfaces) @@ -2991,7 +3019,6 @@ expect(c.nameString).to include("c tbd") # TBD-derated a += surface[:net] - # ua += 1 / TBD.rsi(c, s.filmResistance) * surface[:net] ua += 1 / TBD.rsi(c, surface[:filmRSI]) * surface[:net] end @@ -3271,8 +3298,7 @@ model = model.get argh = { option: "90.1.22|steel.m|default" } - - json = TBD.process(model, argh) + json = TBD.process(model, argh) expect(json).to be_a(Hash) expect(json).to have_key(:io) expect(json).to have_key(:surfaces) @@ -3505,8 +3531,7 @@ # # ... as per 90.1 2022 (non-"parapet" admisible thresholds are much lower). argh = { option: "90.1.22|steel.m|default", parapet: false } - - json = TBD.process(model, argh) + json = TBD.process(model, argh) expect(json).to be_a(Hash) expect(json).to have_key(:io) expect(json).to have_key(:surfaces) @@ -3623,8 +3648,7 @@ model = model.get argh = {option: "90.1.22|steel.m|default", parapet: false} - - json = TBD.process(model, argh) + json = TBD.process(model, argh) expect(json).to be_a(Hash) expect(json).to have_key(:io) expect(json).to have_key(:surfaces) @@ -3711,8 +3735,7 @@ model = model.get argh = {option: "90.1.22|steel.m|default"} - - json = TBD.process(model, argh) + json = TBD.process(model, argh) expect(json).to be_a(Hash) expect(json).to have_key(:io) expect(json).to have_key(:surfaces) @@ -3784,8 +3807,7 @@ model = model.get argh = {option: "90.1.22|steel.m|default", parapet: false} - - json = TBD.process(model, argh) + json = TBD.process(model, argh) expect(json).to be_a(Hash) expect(json).to have_key(:io) expect(json).to have_key(:surfaces) From 1b2384e2a9dbb309ab39634b8ab898651bb1a2e9 Mon Sep 17 00:00:00 2001 From: brgix Date: Fri, 10 Apr 2026 11:24:34 -0400 Subject: [PATCH 05/14] Further revises uprating calcs --- lib/measures/tbd/measure.xml | 6 +++--- lib/measures/tbd/resources/ua.rb | 22 ++++++++++++++++------ lib/tbd/ua.rb | 22 ++++++++++++++++------ spec/tbd_tests_spec.rb | 16 ++++++++++++---- 4 files changed, 47 insertions(+), 19 deletions(-) diff --git a/lib/measures/tbd/measure.xml b/lib/measures/tbd/measure.xml index d831357..b7096bc 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 - d9c660a2-7a01-4b25-86a5-442265b71441 - 2026-04-09T17:40:54Z + b4181a36-d15a-4855-9072-96c54b5762a8 + 2026-04-10T12:50:23Z 99772807 TBDMeasure Thermal Bridging and Derating - TBD @@ -541,7 +541,7 @@ ua.rb rb resource - 47360A10 + 74A5E2C9 utils.rb diff --git a/lib/measures/tbd/resources/ua.rb b/lib/measures/tbd/resources/ua.rb index 330fe27..bb731ff 100644 --- a/lib/measures/tbd/resources/ua.rb +++ b/lib/measures/tbd/resources/ua.rb @@ -32,7 +32,7 @@ module TBD # @param hloss [Numeric] heat loss from major thermal bridging, in W/K # @param ut [Numeric] target overall Ut for lc, in W/m2•K # - # @return [Float] Uo [W/m2•K] required to meet Ut (see logs if 0) + # @return [Float] Uo [W/m2•K] required to meet Ut (see logs if 0 or UMIN) def uo(id = "", lc = nil, area = 0, film = 0, hloss = 0, ut = 0) mth = "TBD::#{__callee__}" cl1 = OpenStudio::Model::LayeredConstruction @@ -57,8 +57,8 @@ def uo(id = "", lc = nil, area = 0, film = 0, hloss = 0, ut = 0) lyr[:index] = nil unless lyr[:index] < lc.layers.size return invalid("#{id} layer index", mth, 3, WRN, 0) unless lyr[:index] return zero("#{id}: net area (m2)", mth, WRN, 0) unless area > TOL - return zero("#{id}: film RSI" , mth, WRN, 0) unless film > 0 - return zero("#{id}: heatloss" , mth, WRN, 0) unless hloss > TOL + return negative("#{id}: film RSI" , mth, WRN, 0) if film < 0 + return zero("#{id}: heatloss" , mth, WRN, 0) if hloss < TOL return zero("#{id}: Ut" , mth, WRN, 0) unless ut > UMIN return invalid("#{id}: Ut" , mth, 4, WRN, 0) unless ut < UMAX @@ -66,12 +66,22 @@ def uo(id = "", lc = nil, area = 0, film = 0, hloss = 0, ut = 0) rt = 1 / ut # target construction Rt r0 = rsi(lc, film) # current construction R0 r = lyr[:r] + rt - r0 # new, un-derated layer RSi - return zero("#{id}: layer RSI", mth, WRN, 0) unless r.abs > 0 + + # Adjust if below admissible threshold. + if r < 0 + zero("#{id}: layer RSI", mth, WRN) + r = RMIN + end # Uprate to counter heat loss from thermal bridging. u = 1 / r u -= hloss / area - return negative("#{id}: new Uo", mth, WRN, 0) if u < UMIN + + # Adjust if beyond admissible range. + if u < UMIN + negative("#{id}: new Uo", mth, WRN) + u = UMIN + end r = 1 / u @@ -359,7 +369,7 @@ def uprate(model = nil, s = {}, argh = {}) # Fetch required, uprated Uo. u = uo(id, col[:lc], col[:area], col[:film], col[:hloss], g[:ut]) - if u < UMIN + unless u > UMIN log(WRN, "Unable to completely uprate '#{id}' (#{mth})") u = UMIN end diff --git a/lib/tbd/ua.rb b/lib/tbd/ua.rb index 330fe27..bb731ff 100644 --- a/lib/tbd/ua.rb +++ b/lib/tbd/ua.rb @@ -32,7 +32,7 @@ module TBD # @param hloss [Numeric] heat loss from major thermal bridging, in W/K # @param ut [Numeric] target overall Ut for lc, in W/m2•K # - # @return [Float] Uo [W/m2•K] required to meet Ut (see logs if 0) + # @return [Float] Uo [W/m2•K] required to meet Ut (see logs if 0 or UMIN) def uo(id = "", lc = nil, area = 0, film = 0, hloss = 0, ut = 0) mth = "TBD::#{__callee__}" cl1 = OpenStudio::Model::LayeredConstruction @@ -57,8 +57,8 @@ def uo(id = "", lc = nil, area = 0, film = 0, hloss = 0, ut = 0) lyr[:index] = nil unless lyr[:index] < lc.layers.size return invalid("#{id} layer index", mth, 3, WRN, 0) unless lyr[:index] return zero("#{id}: net area (m2)", mth, WRN, 0) unless area > TOL - return zero("#{id}: film RSI" , mth, WRN, 0) unless film > 0 - return zero("#{id}: heatloss" , mth, WRN, 0) unless hloss > TOL + return negative("#{id}: film RSI" , mth, WRN, 0) if film < 0 + return zero("#{id}: heatloss" , mth, WRN, 0) if hloss < TOL return zero("#{id}: Ut" , mth, WRN, 0) unless ut > UMIN return invalid("#{id}: Ut" , mth, 4, WRN, 0) unless ut < UMAX @@ -66,12 +66,22 @@ def uo(id = "", lc = nil, area = 0, film = 0, hloss = 0, ut = 0) rt = 1 / ut # target construction Rt r0 = rsi(lc, film) # current construction R0 r = lyr[:r] + rt - r0 # new, un-derated layer RSi - return zero("#{id}: layer RSI", mth, WRN, 0) unless r.abs > 0 + + # Adjust if below admissible threshold. + if r < 0 + zero("#{id}: layer RSI", mth, WRN) + r = RMIN + end # Uprate to counter heat loss from thermal bridging. u = 1 / r u -= hloss / area - return negative("#{id}: new Uo", mth, WRN, 0) if u < UMIN + + # Adjust if beyond admissible range. + if u < UMIN + negative("#{id}: new Uo", mth, WRN) + u = UMIN + end r = 1 / u @@ -359,7 +369,7 @@ def uprate(model = nil, s = {}, argh = {}) # Fetch required, uprated Uo. u = uo(id, col[:lc], col[:area], col[:film], col[:hloss], g[:ut]) - if u < UMIN + unless u > UMIN log(WRN, "Unable to completely uprate '#{id}' (#{mth})") u = UMIN end diff --git a/spec/tbd_tests_spec.rb b/spec/tbd_tests_spec.rb index 2e91d62..a784213 100644 --- a/spec/tbd_tests_spec.rb +++ b/spec/tbd_tests_spec.rb @@ -1818,7 +1818,7 @@ expect(uA.round(2)).to eq(57.45) # Reach NECB required Ut for walls? - ut = uA/m2 + ut = uA / m2 expect(ut.round(3)).to eq(argh[:wall_ut].round(3)) # 0.210 walls.each do |wall| @@ -1905,12 +1905,15 @@ # 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(argh[:wall_uo]).to be_within(0.0001).of(UMIN) # RSi 100 (R568) + expect(argh[:wall_uo].round(4)).to eq(UMIN) # RSi 100 (R568) nb = 0 + m2 = 0 + uA = 0 model.getSurfaces.each do |s| next unless s.surfaceType.downcase == "wall" + next unless s.outsideBoundaryCondition.downcase == "outdoors" c = s.construction expect(c).to_not be_empty @@ -1918,7 +1921,7 @@ next if c.empty? c = c.get - next unless c.nameString.include?("c tbd") + expect(c.nameString).to include("c tbd") lyr = TBD.insulatingLayer(c) expect(lyr).to be_a(Hash) @@ -1931,11 +1934,16 @@ insul = insul.to_StandardOpaqueMaterial expect(insul).to_not be_empty insul = insul.get - expect(insul.thickness.round(3)).to eq(0.079) + expect(insul.thickness.round(3)).to eq(DMAX) # 1m + r = TBD.rsi(c, s.filmResistance) + m2 += s.netArea + uA += s.netArea / r nb += 1 end + ut = uA / m2 + expect((uA/m2).round(2)).to eq(argh[:wall_ut].round(2)) expect(nb).to eq(4) end From eebbcb49d9048f3f223bd3931b26d59c2e41cffc Mon Sep 17 00:00:00 2001 From: brgix Date: Sat, 11 Apr 2026 16:42:16 -0400 Subject: [PATCH 06/14] Edits spec comments --- lib/measures/tbd/measure.xml | 4 ++-- spec/tbd_tests_spec.rb | 41 ++++++++++++++++-------------------- 2 files changed, 20 insertions(+), 25 deletions(-) diff --git a/lib/measures/tbd/measure.xml b/lib/measures/tbd/measure.xml index b7096bc..01dc416 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 - b4181a36-d15a-4855-9072-96c54b5762a8 - 2026-04-10T12:50:23Z + 92bab9dd-3848-44c5-8fc9-cce149d0b4ae + 2026-04-11T20:39:13Z 99772807 TBDMeasure Thermal Bridging and Derating - TBD diff --git a/spec/tbd_tests_spec.rb b/spec/tbd_tests_spec.rb index a784213..1a3508b 100644 --- a/spec/tbd_tests_spec.rb +++ b/spec/tbd_tests_spec.rb @@ -849,7 +849,7 @@ # "code"). So far so good. However, when "(non thermal bridging)" is # retained as a default PSI design set (not as a reference set), all edge # types will necessarily have PSI-factors of 0 W/K per metre. To minimize - # the issue, slight variations (e.g. +/- 0.000001 W/K per inear meter) have + # the issue, slight variations (e.g. +/- 0.000001 W/K per linear meter) have # been added to TBD built-in PSI-factor sets (where required). Without this # fix, undesirable variations in reference UA' tallies may occur. # @@ -1699,7 +1699,8 @@ # # Uo = 0.277 - ( ∑psi • L )/A # - # The method exits with an ERROR in 2x cases: + # If impossible to meet the requested Ut, TBD hardsets Uo to UMIN while + # raising warnings, namely when: # - calculated Uo is negative, i.e. ( ∑psi • L )/A > 0.277 # - calculated layer r violates E+ material constraints, e.g. # - too conductive @@ -1741,7 +1742,7 @@ # Although the roof construction is correctly uprated, it is not possible to # completely uprate the wall construction. It is therefore capped at the # minimum allowed Uo-factor, or ~9 ft of XPS insulation. - expect(argh[:wall_uo].round(3)).to eq(0.010) # RSi 100.00 (R568) + expect(argh[:wall_uo].round(3)).to eq(UMIN) # RSi 100.00 (R568) expect(argh[:roof_uo].round(3)).to eq(0.121) # RSi 8.26 ( R47) # -- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- -- # @@ -3292,7 +3293,7 @@ end end - it "can check for balcony sills (ASHRAE 90.1 2022)" do + it "can check for balcony sills (ASHRAE 90.1 2022/25)" do translator = OpenStudio::OSVersion::VersionTranslator.new TBD.clean! @@ -3409,32 +3410,26 @@ # Turin. The model nonetheless remains an interesting (~extreme) test case # for TBD. Except along the South parapet, the transition from "wall-to-roof" # and "roof-to-skylight" are one and the same. So is the edge a :skylight - # edge? or a :parapet (or :roof) edge? They're both. In such cases, the final - # selection in TBD is based on the greatest PSI-factor. In ASHRAE 90.1 2022, - # only "vertical fenestration" edge PSI-factors are explicitely - # stated/published. For this reason, the 8x TBD-built-in ASHRAE PSI sets - # have 0 W/K per meter assigned for any non-regulated edge, e.g.: + # edge? or a :parapet (or :roof) edge? They're both. In such cases, the + # final selection in TBD is based on the greatest PSI-factor. + # + # In ASHRAE 90.1 2022/2025, only "vertical fenestration" edge PSI-factors + # are explicitely stated/published. Many other edges, such as: # # - skylight perimeters # - non-fenestrated door perimeters # - corners # - # There are (possibly) 2x admissible interpretations of how to treat - # non-regulated heat losss (edges as linear thermal bridges) in 90.1: - # 1. assign 0 W/K•m for both proposed design and budget building models - # 2. assign more realistic PSI-factors, equally to both proposed/budget + # ... fall under the scope of requirement 5.5.5.5. There is much uncertainty + # on how to model items falling under 5.5.5.5. For this reason, the 8x + # TBD-built-in ASHRAE PSI sets have 0 W/K per meter assigned for edges under + # 5.5.5.5. This is discussed here: # - # In both cases, the treatment of non-regulated heat loss remains "neutral" - # between both proposed design and budget building models. Option #2 remains - # closer to reality (more heat loss in winter, likely more heat gain in - # summer), which is preferable for HVAC autosizing. Yet 90.1 (2022) ECB - # doesn't seem to afford this type of flexibility, contrary to the "neutral" - # treatment of (non-regulated) miscellaneous (process) loads. So for now, - # TBD's built-in ASHRAE 90.1 2022 (A10) PSI-factor sets recflect option #1. + # unmethours.com/question/97085/901-2022-requirements-for-linear-thermal-bridges # - # Users who choose option #2 can always write up a custom ASHRAE 90.1 (A10) - # PSI-factor set on file (tbd.json), initially based on the built-in 90.1 - # sets while resetting non-zero PSI-factors. + # Users can always write up a custom ASHRAE 90.1 (A10) PSI-factor set on + # file (tbd.json), initially based on the built-in 90.1 sets while resetting + # non-zero PSI-factors. expect(n_edges_at_grade ).to eq( 0) expect(n_edges_as_balconies ).to eq( 2) expect(n_edges_as_balconysills ).to eq( 2) # (2x instances of GlassDoor) From c90bcd21ab3e6de6e6d3cc474116e704f4a8ebb3 Mon Sep 17 00:00:00 2001 From: brgix Date: Mon, 13 Apr 2026 05:45:14 -0400 Subject: [PATCH 07/14] Removes fileutils --- spec/tbd_tests_spec.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/spec/tbd_tests_spec.rb b/spec/tbd_tests_spec.rb index 1a3508b..4883af6 100644 --- a/spec/tbd_tests_spec.rb +++ b/spec/tbd_tests_spec.rb @@ -1,5 +1,4 @@ require "tbd" -require "fileutils" RSpec.describe TBD do TOL = TBD::TOL.dup From 743febf77e0eb5f151d9a34390551bd07fa93f57 Mon Sep 17 00:00:00 2001 From: brgix Date: Mon, 13 Apr 2026 15:22:40 -0400 Subject: [PATCH 08/14] Pulls final OSut edits --- lib/measures/tbd/measure.xml | 6 +++--- lib/measures/tbd/resources/utils.rb | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/measures/tbd/measure.xml b/lib/measures/tbd/measure.xml index 01dc416..4059ab3 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 - 92bab9dd-3848-44c5-8fc9-cce149d0b4ae - 2026-04-11T20:39:13Z + 50597d99-2f0b-438b-a2aa-b830489f94f4 + 2026-04-13T19:21:12Z 99772807 TBDMeasure Thermal Bridging and Derating - TBD @@ -547,7 +547,7 @@ utils.rb rb resource - BAFE3A46 + 26EC8C4F version.rb diff --git a/lib/measures/tbd/resources/utils.rb b/lib/measures/tbd/resources/utils.rb index 5854c2a..4857661 100644 --- a/lib/measures/tbd/resources/utils.rb +++ b/lib/measures/tbd/resources/utils.rb @@ -527,7 +527,7 @@ def assignUniqueMaterial(lc = nil, index = nil) ## # 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 + # massless). Unless material uniqueness 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). # From 5f591a7740ed07895611869159a47c4844adbbf3 Mon Sep 17 00:00:00 2001 From: brgix Date: Tue, 14 Apr 2026 07:49:32 -0400 Subject: [PATCH 09/14] Pulls OSut gem v090 --- Gemfile | 2 -- tbd.gemspec | 3 +-- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/Gemfile b/Gemfile index 54ddd49..b4e2a20 100644 --- a/Gemfile +++ b/Gemfile @@ -1,5 +1,3 @@ source "https://rubygems.org" -gem "osut", git: "https://github.com/rd2/osut", branch: "airfilm" - gemspec diff --git a/tbd.gemspec b/tbd.gemspec index b609b28..14956fb 100644 --- a/tbd.gemspec +++ b/tbd.gemspec @@ -29,10 +29,9 @@ Gem::Specification.new do |s| s.metadata = {} s.add_dependency "topolys", "~> 0" - # s.add_dependency "osut", "~> 0" + s.add_dependency "osut", "~> 0" s.add_dependency "json-schema", "~> 4" - s.add_development_dependency "osut", "~> 0.8.3" s.add_development_dependency "bundler", "~> 2.1" s.add_development_dependency "rake", "~> 13.0" s.add_development_dependency "rspec", "~> 3.11" From d85d937a2630ba39b8e64e3c8af9dcad33588f9f Mon Sep 17 00:00:00 2001 From: brgix Date: Wed, 15 Apr 2026 09:01:53 -0400 Subject: [PATCH 10/14] Fixes 'uo' return value --- lib/measures/tbd/measure.xml | 6 +- lib/measures/tbd/resources/ua.rb | 2 +- lib/tbd/ua.rb | 2 +- spec/tbd_tests_spec.rb | 187 ++++++++++++++++++++++++++++++- 4 files changed, 187 insertions(+), 10 deletions(-) diff --git a/lib/measures/tbd/measure.xml b/lib/measures/tbd/measure.xml index 4059ab3..20d8e5b 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 - 50597d99-2f0b-438b-a2aa-b830489f94f4 - 2026-04-13T19:21:12Z + cad7d911-00a4-439e-b147-e38ded250156 + 2026-04-15T12:05:05Z 99772807 TBDMeasure Thermal Bridging and Derating - TBD @@ -541,7 +541,7 @@ ua.rb rb resource - 74A5E2C9 + 92178848 utils.rb diff --git a/lib/measures/tbd/resources/ua.rb b/lib/measures/tbd/resources/ua.rb index bb731ff..87f1700 100644 --- a/lib/measures/tbd/resources/ua.rb +++ b/lib/measures/tbd/resources/ua.rb @@ -128,7 +128,7 @@ def uo(id = "", lc = nil, area = 0, film = 0, hloss = 0, ut = 0) h = format "%.3f", loss log(WRN, "Can't set #{h} W/K to #{id} #{mth}") if loss > TOL - u + uo end ## diff --git a/lib/tbd/ua.rb b/lib/tbd/ua.rb index bb731ff..87f1700 100644 --- a/lib/tbd/ua.rb +++ b/lib/tbd/ua.rb @@ -128,7 +128,7 @@ def uo(id = "", lc = nil, area = 0, film = 0, hloss = 0, ut = 0) h = format "%.3f", loss log(WRN, "Can't set #{h} W/K to #{id} #{mth}") if loss > TOL - u + uo end ## diff --git a/spec/tbd_tests_spec.rb b/spec/tbd_tests_spec.rb index 4883af6..32f8199 100644 --- a/spec/tbd_tests_spec.rb +++ b/spec/tbd_tests_spec.rb @@ -1678,8 +1678,7 @@ # Long story short: there will inevitably be cases where TBD is unable to # "uprate" a construction prior to "derating". This is neither a TBD bug # nor an RP-1365/ISO model limitation. It is simply "bad" input, although - # likely unintentional. Nevertheless, TBD should exit in such cases with - # an ERROR message. + # likely unintentional. # # And if one were to instead model each of the OpenStudio walls described # above as 2x distinct OpenStudio surfaces? e.g.: @@ -1742,7 +1741,7 @@ # completely uprate the wall construction. It is therefore capped at the # minimum allowed Uo-factor, or ~9 ft of XPS insulation. expect(argh[:wall_uo].round(3)).to eq(UMIN) # RSi 100.00 (R568) - expect(argh[:roof_uo].round(3)).to eq(0.121) # RSi 8.26 ( R47) + expect(argh[:roof_uo].round(3)).to eq(0.118) # RSi 8.47 ( R48) # -- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- -- # # Final attempt, with PSI-factors of 0.09 W/K per linear metre (JSON file). @@ -1775,8 +1774,8 @@ expect(argh).to have_key(:roof_uo) expect(argh[:wall_uo]).to_not be_nil expect(argh[:roof_uo]).to_not be_nil - expect(argh[:wall_uo].round(3)).to eq(0.089) # RSi 11.24 (R64) - expect(argh[:roof_uo].round(3)).to eq(0.132) # RSi 7.58 (R43) + expect(argh[:wall_uo].round(3)).to eq(0.086) # RSi 11.63 (R66) + expect(argh[:roof_uo].round(3)).to eq(0.129) # RSi 7.75 (R44) uA = 0 m2 = 0 @@ -1945,6 +1944,184 @@ ut = uA / m2 expect((uA/m2).round(2)).to eq(argh[:wall_ut].round(2)) expect(nb).to eq(4) + + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + # Side test, simple model: + # - 3x roofceiling surfaces + # - different tilts + # - different outside boundary conditions + TBD.clean! + area = 0 + fA = 0 + model = OpenStudio::Model::Model.new + + # Surface 1: Flat roof, outdoor-facing. + vtx = OpenStudio::Point3dVector.new + vtx << OpenStudio::Point3d.new( 2, 9, 10) + vtx << OpenStudio::Point3d.new( 2, 2, 10) + vtx << OpenStudio::Point3d.new( 9, 2, 10) + vtx << OpenStudio::Point3d.new( 9, 9, 10) + roof = OpenStudio::Model::Surface.new(vtx, model) + roof.setName("Roof") + expect(roof.netArea.round).to eq(49) + area += roof.netArea + fA += roof.netArea / TBD.filmResistances(:roof, roof.tilt) + + # Surface 2: Flat ceiling, interzone. + vtx = OpenStudio::Point3dVector.new + vtx << OpenStudio::Point3d.new( 2,12, 10) + vtx << OpenStudio::Point3d.new( 2, 9, 10) + vtx << OpenStudio::Point3d.new( 9, 9, 10) + vtx << OpenStudio::Point3d.new( 9,12, 10) + clng = OpenStudio::Model::Surface.new(vtx, model) + clng.setName("Ceiling") + expect(clng.netArea.round).to eq(21) + area += clng.netArea + fA += clng.netArea / TBD.filmResistances(:ceiling, clng.tilt) + + # Surface 3: Sloped roof, outdoor-facing. + vtx = OpenStudio::Point3dVector.new + vtx << OpenStudio::Point3d.new( 9,12, 10) + vtx << OpenStudio::Point3d.new( 9, 2, 10) + vtx << OpenStudio::Point3d.new(12, 2, 8) + vtx << OpenStudio::Point3d.new(12,12, 8) + slpe = OpenStudio::Model::Surface.new(vtx, model) + slpe.setName("Sloped Roof") + expect((slpe.tilt * 180/Math::PI).round(1)).to eq(33.7) # deg (2:3 slope) + expect(slpe.netArea.round(1)).to eq(36.1) + area += slpe.netArea + fA += slpe.netArea / TBD.filmResistances(:roof, slpe.tilt) + + # Area-weighted average of surface air film resistances. + fR = 1 / ( fA / area ) + expect(fR.round(4)).to eq(0.1514) + + # NECB 2025 prescriptive target (CZ7), ~R47. + u = 0.121 + + # Shared layered construction. + specs = {type: :roof} + lc = TBD.genConstruction(model, specs) + id = lc.nameString + expect(lc).to_not be_nil + expect(lc).to be_a(OpenStudio::Model::LayeredConstruction) + expect(lc.layers.size).to eq(3) + + # Assign shared construction to each surface. + expect(roof.setConstruction(lc)).to be true + expect(clng.setConstruction(lc)).to be true + expect(slpe.setConstruction(lc)).to be true + + # Uprated Uo to meet NECB 2025 prescriptive requirements. + u0 = TBD.uo(id, lc, area, fR, 5.0, u) + expect(TBD.status).to be_zero + expect(TBD.logs).to be_empty + expect(u0.round(4)).to eq(0.0773) # R73, not R47 + + insulation = TBD.insulatingLayer(lc) + expect(insulation).to be_a(Hash) + expect(insulation).to have_key(:r) + expect(insulation).to have_key(:type) + expect(insulation).to have_key(:index) + expect(insulation[:index]).to eq(1) + + # TBD's 'uo' alters the shared construction. + lc1 = roof.construction + lc2 = clng.construction + lc3 = slpe.construction + expect(lc1).to_not be_empty + expect(lc2).to_not be_empty + expect(lc3).to_not be_empty + expect(lc1.get.to_LayeredConstruction).to_not be_empty + expect(lc2.get.to_LayeredConstruction).to_not be_empty + expect(lc3.get.to_LayeredConstruction).to_not be_empty + lc1 = lc1.get.to_LayeredConstruction.get + lc2 = lc2.get.to_LayeredConstruction.get + lc3 = lc3.get.to_LayeredConstruction.get + expect(lc1).to eq(lc2) + expect(lc2).to eq(lc3) + + # Ensure uniqueness. + lc1 = lc1.clone(model).to_LayeredConstruction.get + lc2 = lc2.clone(model).to_LayeredConstruction.get + lc3 = lc3.clone(model).to_LayeredConstruction.get + expect(lc1).to_not eq(lc2) + expect(lc2).to_not eq(lc3) + lc1.setName("Roof construction") + lc2.setName("Ceiling construction") + lc3.setName("Sloped Roof construction") + expect(roof.setConstruction(lc1)).to be true + expect(clng.setConstruction(lc2)).to be true + expect(slpe.setConstruction(lc3)).to be true + u1 = 1 / TBD.rsi(lc1, TBD.filmResistances(:roof, roof.tilt)) + u2 = 1 / TBD.rsi(lc2, TBD.filmResistances(:ceiling, clng.tilt)) + u3 = 1 / TBD.rsi(lc3, TBD.filmResistances(:roof, slpe.tilt)) + expect(u1.round(5)).to eq(0.07740) # R73.36 + expect(u2.round(5)).to eq(0.07662) # R74.11 + expect(u3.round(5)).to eq(0.07738) # R73.38 + + # Matains overall area-weighted (uprated) u0. + uA = 0 + uA += roof.netArea * u1 + uA += clng.netArea * u2 + uA += slpe.netArea * u3 + uU = uA / area + expect(uU.round(2)).to eq(u0.round(2)) + + spc1 = {} + spc2 = {} + spc3 = {} + + # Generate new derated insulation materials. + spc1[:heatloss] = 2.0 # W/K, larger flat roof + spc2[:heatloss] = 1.0 # W/K, smaller interzone ceiling + spc3[:heatloss] = 2.0 # W/K, larger sloped roof + spc1[:net ] = roof.netArea + spc2[:net ] = clng.netArea + spc3[:net ] = slpe.netArea + spc1[:index ] = 1 + spc2[:index ] = 1 + spc3[:index ] = 1 + spc1[:r ] = insulation[:r] + spc2[:r ] = insulation[:r] + spc3[:r ] = insulation[:r] + spc1[:ltype ] = :standard + spc2[:ltype ] = :standard + spc3[:ltype ] = :standard + + m1 = TBD.derate(roof.nameString, spc1, lc1) + m2 = TBD.derate(clng.nameString, spc2, lc2) + m3 = TBD.derate(slpe.nameString, spc3, lc3) + expect(m1.nameString).to eq("Roof uprated m tbd") + expect(m2.nameString).to eq("Ceiling uprated m tbd") + expect(m3.nameString).to eq("Sloped Roof uprated m tbd") + expect(m1.thickness.round(4)).to eq(0.1256) + expect(m2.thickness.round(4)).to eq(0.1256) + expect(m3.thickness.round(4)).to eq(0.1256) + expect(m1.thermalConductivity.round(4)).to eq(0.0151) + expect(m2.thermalConductivity.round(4)).to eq(0.0160) + expect(m3.thermalConductivity.round(4)).to eq(0.0170) + + # Assign new derated material to each construction. + expect(lc1.setLayer(1, m1)).to be true + expect(lc2.setLayer(1, m2)).to be true + expect(lc3.setLayer(1, m3)).to be true + + # Derated construction RSi values. + dR1 = TBD.rsi(lc1, TBD.filmResistances(:roof, roof.tilt)) + dR2 = TBD.rsi(lc2, TBD.filmResistances(:ceiling, clng.tilt)) + dR3 = TBD.rsi(lc3, TBD.filmResistances(:roof, slpe.tilt)) + + uA = 0 + uA += roof.netArea / dR1 + uA += clng.netArea / dR2 + uA += slpe.netArea / dR3 + uT = uA / area + + # Area-weighted Ut factors meet NECB requirement. + expect(uT.round(3)).to eq(u.round(3)) + expect(TBD.logs).to be_empty + expect(TBD.status).to be_zero end it "can test Hash inputs" do From 5123e37332770e4b80f92943160231c97c8aa2e4 Mon Sep 17 00:00:00 2001 From: brgix Date: Wed, 15 Apr 2026 09:28:24 -0400 Subject: [PATCH 11/14] Fixes typo --- spec/tbd_tests_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/tbd_tests_spec.rb b/spec/tbd_tests_spec.rb index 32f8199..e279559 100644 --- a/spec/tbd_tests_spec.rb +++ b/spec/tbd_tests_spec.rb @@ -2060,7 +2060,7 @@ expect(u2.round(5)).to eq(0.07662) # R74.11 expect(u3.round(5)).to eq(0.07738) # R73.38 - # Matains overall area-weighted (uprated) u0. + # Maintains overall area-weighted (uprated) u0. uA = 0 uA += roof.netArea * u1 uA += clng.netArea * u2 From 667763b7056f4169876aa6fca83cf8d9fe820a07 Mon Sep 17 00:00:00 2001 From: brgix Date: Thu, 16 Apr 2026 08:04:45 -0400 Subject: [PATCH 12/14] Fixes uprating for selected constructions --- lib/measures/tbd/measure.xml | 10 ++-- lib/measures/tbd/resources/geo.rb | 1 - lib/measures/tbd/resources/psi.rb | 21 +++----- lib/measures/tbd/resources/ua.rb | 79 ++++++++++++++++++++----------- lib/tbd/geo.rb | 1 - lib/tbd/psi.rb | 21 +++----- lib/tbd/ua.rb | 79 ++++++++++++++++++++----------- spec/tbd_tests_spec.rb | 42 +++++++++++++++- 8 files changed, 161 insertions(+), 93 deletions(-) diff --git a/lib/measures/tbd/measure.xml b/lib/measures/tbd/measure.xml index 20d8e5b..eaa7479 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 - cad7d911-00a4-439e-b147-e38ded250156 - 2026-04-15T12:05:05Z + 5bc790cf-6b1f-4c00-add7-0741b40fe621 + 2026-04-16T12:02:04Z 99772807 TBDMeasure Thermal Bridging and Derating - TBD @@ -499,7 +499,7 @@ geo.rb rb resource - 86AE9E8B + AD9546E1 geometry.rb @@ -523,7 +523,7 @@ psi.rb rb resource - 23EDD2E7 + 9F7B97ED tbd.rb @@ -541,7 +541,7 @@ ua.rb rb resource - 92178848 + 0ADD60B8 utils.rb diff --git a/lib/measures/tbd/resources/geo.rb b/lib/measures/tbd/resources/geo.rb index 4eab210..905bade 100644 --- a/lib/measures/tbd/resources/geo.rb +++ b/lib/measures/tbd/resources/geo.rb @@ -517,7 +517,6 @@ def properties(surface = nil, argh = {}) end unless u.is_a?(Numeric) - # r = rsi(c, surface.filmResistance) r = rsi(c, surf[:filmRSI]) if r < TOL diff --git a/lib/measures/tbd/resources/psi.rb b/lib/measures/tbd/resources/psi.rb index 0f1d747..dba602b 100644 --- a/lib/measures/tbd/resources/psi.rb +++ b/lib/measures/tbd/resources/psi.rb @@ -1424,8 +1424,10 @@ def derate(id = "", s = {}, lc = nil) 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 + if de_r < RMIN + de_r = RMIN + loss = (de_u - 1 / de_r) * net + end unless m.setThermalResistance(de_r) return invalid("Can't derate #{id}: RSi#{de_r.round(2)}", mth) @@ -2982,8 +2984,7 @@ def process(model = nil, argh = {}) s = model.getSurfaceByName(id) next if s.empty? - s = s.get - + s = s.get index = surface[:index ] current_c = surface[:construction] c = current_c.clone(model).to_LayeredConstruction.get @@ -2996,7 +2997,6 @@ def process(model = nil, argh = {}) if m c.setLayer(index, m) c.setName("#{id} c tbd") - # current_R = rsi(current_c, s.filmResistance) current_R = rsi(current_c, surface[:filmRSI]) # In principle, the derated "ratio" could be calculated simply by @@ -3037,7 +3037,6 @@ def process(model = nil, argh = {}) # Compute updated RSi value from layers. updated_c = s.construction.get.to_LayeredConstruction.get - # updated_R = rsi(updated_c, s.filmResistance) updated_R = rsi(updated_c, surface[:filmRSI]) ratio = -(current_R - updated_R) * 100 / current_R @@ -3053,12 +3052,6 @@ def process(model = nil, argh = {}) next unless surface.key?(:filmRSI) next if surface.key?(:u) - # s = model.getSurfaceByName(id) - # msg = "Skipping missing surface '#{id}' (#{mth})" - # log(ERR, msg) if s.empty? - # next if s.empty? - - # surface[:u] = 1.0 / rsi(surface[:construction], s.get.filmResistance) surface[:u] = 1.0 / rsi(surface[:construction], surface[:filmRSI]) end @@ -3209,8 +3202,8 @@ def exit(runner = nil, argh = {}) uo = format("%.3f", g[:uo]) ut = format("%.3f", g[:ut]) - output = "An initial #{label.to_s} Uo of #{uo} W/m2•K is required to " \ - "achieve an overall Ut of #{ut} W/m2•K for #{g[:op]}" + output = "An area-weighted #{label.to_s} Uo of #{uo} W/m2•K is " \ + "required to meet an overall Ut of #{ut} W/m2•K for #{g[:op]}" u_t << output runner.registerInfo(output) end diff --git a/lib/measures/tbd/resources/ua.rb b/lib/measures/tbd/resources/ua.rb index 87f1700..0c65f0f 100644 --- a/lib/measures/tbd/resources/ua.rb +++ b/lib/measures/tbd/resources/ua.rb @@ -55,12 +55,12 @@ def uo(id = "", lc = nil, area = 0, film = 0, hloss = 0, ut = 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, WRN, 0) unless lyr[:index] - return zero("#{id}: net area (m2)", mth, WRN, 0) unless area > TOL - return negative("#{id}: film RSI" , mth, WRN, 0) if film < 0 - return zero("#{id}: heatloss" , mth, WRN, 0) if hloss < TOL - return zero("#{id}: Ut" , mth, WRN, 0) unless ut > UMIN - return invalid("#{id}: Ut" , mth, 4, WRN, 0) unless ut < UMAX + return invalid("#{id} layer index", mth, 3, DBG, 0) unless lyr[:index] + return zero("#{id}: net area (m2)", mth, DBG, 0) unless area > TOL + return negative("#{id}: film RSI" , mth, DBG, 0) if film < 0 + return zero("#{id}: heatloss" , mth, DBG, 0) if hloss < TOL + return zero("#{id}: Ut" , mth, DBG, 0) unless ut > UMIN + return invalid("#{id}: Ut" , mth, 4, DBG, 0) unless ut < UMAX # Calculate initial layer RSi to initially meet Ut target. rt = 1 / ut # target construction Rt @@ -69,17 +69,17 @@ def uo(id = "", lc = nil, area = 0, film = 0, hloss = 0, ut = 0) # Adjust if below admissible threshold. if r < 0 - zero("#{id}: layer RSI", mth, WRN) + zero("#{id}: layer RSI", mth, INF) r = RMIN end # Uprate to counter heat loss from thermal bridging. u = 1 / r - u -= hloss / area + u -= (hloss / area) # Adjust if beyond admissible range. if u < UMIN - negative("#{id}: new Uo", mth, WRN) + negative("#{id}: new Uo", mth, INF) u = UMIN end @@ -92,8 +92,10 @@ def uo(id = "", lc = nil, area = 0, film = 0, hloss = 0, ut = 0) m = m.get.clone(model).to_MasslessOpaqueMaterial.get m.setName("#{id} uprated") - r = RMIN if r < RMIN - loss = (u - 1 / r) * area if r < RMIN + if r < RMIN + r = RMIN + loss = (u - 1 / r) * area + end unless m.setThermalResistance(r) return invalid("Can't uprate #{id}: RSi#{r.round(2)}", mth, 0, DBG, 0) @@ -123,10 +125,11 @@ def uo(id = "", lc = nil, area = 0, film = 0, hloss = 0, ut = 0) return invalid("Can't ID insulating layer", mth, 0, DBG, 0) unless m lc.setLayer(lyr[:index], m) - uo = 1 / rsi(lc, film) + ro = rsi(lc, film) + uo = ro < RMIN ? UMIN : 1 / ro h = format "%.3f", loss - log(WRN, "Can't set #{h} W/K to #{id} #{mth}") if loss > TOL + log(INF, "Can't set #{h} W/K to #{id} #{mth}") if loss > TOL uo end @@ -208,9 +211,10 @@ def uprate(model = nil, s = {}, argh = {}) # Collection of one or several constructions to uprate. coll = {} - op = g[:op].downcase + op = g[:op] - if tout.include?(op) # uprate all constructions of same type, e.g. walls + # Uprate ALL constructions of same type, e.g. walls. + if tout.include?(op.downcase) s.each do |nom, surface| next unless surface.key?(:deratable) next unless surface.key?(:type) @@ -273,11 +277,11 @@ def uprate(model = nil, s = {}, argh = {}) next end - coll[id] = {} + lc = lc.get + + coll[id] = {} coll[id][:lc ] = lc coll[id][:s ] = {} - coll[id][:idx ] = surface[:index] - coll[id][:ltp ] = surface[:ltype] coll[id][:hloss] = 0 coll[id][:area ] = 0 coll[id][:film ] = 0 @@ -344,7 +348,7 @@ def uprate(model = nil, s = {}, argh = {}) next if surface[:type ] == typ next if coll[id][:s].key?(nom) - log(WRN, "Cloning '#{nom}' construction - not '#{id}' (#{mth})") + log(INF, "Cloning '#{nom}' construction - not '#{id}' (#{mth})") srf = model.getSurfaceByName(nom) next if srf.empty? @@ -360,11 +364,16 @@ def uprate(model = nil, s = {}, argh = {}) col[:s].values.each do |item| col[:hloss] += item[:h] col[:area ] += item[:a] - col[:fA ] += item[:a] / item[:f] + col[:fA ] += item[:a] / item[:f] unless item[:f] < 0 + end + + if col[:area] < TOL + empty("#{id} area", mth, WRN) + next end # Area-weighted surface air film resistances. - col[:film] = 1 / ( col[:fA] / col[:area] ) + col[:film] = 1 / (col[:fA] / col[:area]) # Fetch required, uprated Uo. u = uo(id, col[:lc], col[:area], col[:film], col[:hloss], g[:ut]) @@ -394,9 +403,11 @@ def uprate(model = nil, s = {}, argh = {}) area = coll.values.sum { |col| col[:area] } uA = coll.values.sum { |col| col[:uA ] } - argh[:wall_uo ] = uA / area if typ == :wall - argh[:roof_uo ] = uA / area if typ == :ceiling - argh[:floor_uo] = uA / area if typ == :floor + if area > TOL + argh[:wall_uo ] = uA / area if typ == :wall + argh[:roof_uo ] = uA / area if typ == :ceiling + argh[:floor_uo] = uA / area if typ == :floor + end end end @@ -455,20 +466,28 @@ def qc33(s = {}, sets = nil, spts = true) ref = 1 / 3.60 if surface[:type] == :wall # Adjust for lower heating setpoint (assumes -25C design conditions). - ref *= 43 / (heating + 25) if heating < 18 && cooling > 40 + if heating > -25 && heating < 18 && cooling > 40 + ref *= 43 / (heating + 25) + end surface[:ref] = ref if surface.key?(:skylights) # loop through subsurfaces ref = 2.85 - ref *= 43 / (heating + 25) if heating < 18 && cooling > 40 + + if heating > -25 && heating < 18 && cooling > 40 + ref *= 43 / (heating + 25) + end surface[:skylights].values.map { |skylight| skylight[:ref] = ref } end if surface.key?(:windows) ref = 2.0 - ref *= 43 / (heating + 25) if heating < 18 && cooling > 40 + + if heating > -25 && heating < 18 && cooling > 40 + ref *= 43 / (heating + 25) + end surface[:windows].values.map { |window| window[:ref] = ref } end @@ -477,7 +496,11 @@ def qc33(s = {}, sets = nil, spts = true) surface[:doors].each do |i, door| ref = 0.9 ref = 2.0 if door.key?(:glazed) && door[:glazed] - ref *= 43 / (heating + 25) if heating < 18 && cooling > 40 + + if heating > -25 && heating < 18 && cooling > 40 + ref *= 43 / (heating + 25) + end + door[:ref] = ref end end diff --git a/lib/tbd/geo.rb b/lib/tbd/geo.rb index 4eab210..905bade 100644 --- a/lib/tbd/geo.rb +++ b/lib/tbd/geo.rb @@ -517,7 +517,6 @@ def properties(surface = nil, argh = {}) end unless u.is_a?(Numeric) - # r = rsi(c, surface.filmResistance) r = rsi(c, surf[:filmRSI]) if r < TOL diff --git a/lib/tbd/psi.rb b/lib/tbd/psi.rb index 0f1d747..dba602b 100644 --- a/lib/tbd/psi.rb +++ b/lib/tbd/psi.rb @@ -1424,8 +1424,10 @@ def derate(id = "", s = {}, lc = nil) 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 + if de_r < RMIN + de_r = RMIN + loss = (de_u - 1 / de_r) * net + end unless m.setThermalResistance(de_r) return invalid("Can't derate #{id}: RSi#{de_r.round(2)}", mth) @@ -2982,8 +2984,7 @@ def process(model = nil, argh = {}) s = model.getSurfaceByName(id) next if s.empty? - s = s.get - + s = s.get index = surface[:index ] current_c = surface[:construction] c = current_c.clone(model).to_LayeredConstruction.get @@ -2996,7 +2997,6 @@ def process(model = nil, argh = {}) if m c.setLayer(index, m) c.setName("#{id} c tbd") - # current_R = rsi(current_c, s.filmResistance) current_R = rsi(current_c, surface[:filmRSI]) # In principle, the derated "ratio" could be calculated simply by @@ -3037,7 +3037,6 @@ def process(model = nil, argh = {}) # Compute updated RSi value from layers. updated_c = s.construction.get.to_LayeredConstruction.get - # updated_R = rsi(updated_c, s.filmResistance) updated_R = rsi(updated_c, surface[:filmRSI]) ratio = -(current_R - updated_R) * 100 / current_R @@ -3053,12 +3052,6 @@ def process(model = nil, argh = {}) next unless surface.key?(:filmRSI) next if surface.key?(:u) - # s = model.getSurfaceByName(id) - # msg = "Skipping missing surface '#{id}' (#{mth})" - # log(ERR, msg) if s.empty? - # next if s.empty? - - # surface[:u] = 1.0 / rsi(surface[:construction], s.get.filmResistance) surface[:u] = 1.0 / rsi(surface[:construction], surface[:filmRSI]) end @@ -3209,8 +3202,8 @@ def exit(runner = nil, argh = {}) uo = format("%.3f", g[:uo]) ut = format("%.3f", g[:ut]) - output = "An initial #{label.to_s} Uo of #{uo} W/m2•K is required to " \ - "achieve an overall Ut of #{ut} W/m2•K for #{g[:op]}" + output = "An area-weighted #{label.to_s} Uo of #{uo} W/m2•K is " \ + "required to meet an overall Ut of #{ut} W/m2•K for #{g[:op]}" u_t << output runner.registerInfo(output) end diff --git a/lib/tbd/ua.rb b/lib/tbd/ua.rb index 87f1700..0c65f0f 100644 --- a/lib/tbd/ua.rb +++ b/lib/tbd/ua.rb @@ -55,12 +55,12 @@ def uo(id = "", lc = nil, area = 0, film = 0, hloss = 0, ut = 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, WRN, 0) unless lyr[:index] - return zero("#{id}: net area (m2)", mth, WRN, 0) unless area > TOL - return negative("#{id}: film RSI" , mth, WRN, 0) if film < 0 - return zero("#{id}: heatloss" , mth, WRN, 0) if hloss < TOL - return zero("#{id}: Ut" , mth, WRN, 0) unless ut > UMIN - return invalid("#{id}: Ut" , mth, 4, WRN, 0) unless ut < UMAX + return invalid("#{id} layer index", mth, 3, DBG, 0) unless lyr[:index] + return zero("#{id}: net area (m2)", mth, DBG, 0) unless area > TOL + return negative("#{id}: film RSI" , mth, DBG, 0) if film < 0 + return zero("#{id}: heatloss" , mth, DBG, 0) if hloss < TOL + return zero("#{id}: Ut" , mth, DBG, 0) unless ut > UMIN + return invalid("#{id}: Ut" , mth, 4, DBG, 0) unless ut < UMAX # Calculate initial layer RSi to initially meet Ut target. rt = 1 / ut # target construction Rt @@ -69,17 +69,17 @@ def uo(id = "", lc = nil, area = 0, film = 0, hloss = 0, ut = 0) # Adjust if below admissible threshold. if r < 0 - zero("#{id}: layer RSI", mth, WRN) + zero("#{id}: layer RSI", mth, INF) r = RMIN end # Uprate to counter heat loss from thermal bridging. u = 1 / r - u -= hloss / area + u -= (hloss / area) # Adjust if beyond admissible range. if u < UMIN - negative("#{id}: new Uo", mth, WRN) + negative("#{id}: new Uo", mth, INF) u = UMIN end @@ -92,8 +92,10 @@ def uo(id = "", lc = nil, area = 0, film = 0, hloss = 0, ut = 0) m = m.get.clone(model).to_MasslessOpaqueMaterial.get m.setName("#{id} uprated") - r = RMIN if r < RMIN - loss = (u - 1 / r) * area if r < RMIN + if r < RMIN + r = RMIN + loss = (u - 1 / r) * area + end unless m.setThermalResistance(r) return invalid("Can't uprate #{id}: RSi#{r.round(2)}", mth, 0, DBG, 0) @@ -123,10 +125,11 @@ def uo(id = "", lc = nil, area = 0, film = 0, hloss = 0, ut = 0) return invalid("Can't ID insulating layer", mth, 0, DBG, 0) unless m lc.setLayer(lyr[:index], m) - uo = 1 / rsi(lc, film) + ro = rsi(lc, film) + uo = ro < RMIN ? UMIN : 1 / ro h = format "%.3f", loss - log(WRN, "Can't set #{h} W/K to #{id} #{mth}") if loss > TOL + log(INF, "Can't set #{h} W/K to #{id} #{mth}") if loss > TOL uo end @@ -208,9 +211,10 @@ def uprate(model = nil, s = {}, argh = {}) # Collection of one or several constructions to uprate. coll = {} - op = g[:op].downcase + op = g[:op] - if tout.include?(op) # uprate all constructions of same type, e.g. walls + # Uprate ALL constructions of same type, e.g. walls. + if tout.include?(op.downcase) s.each do |nom, surface| next unless surface.key?(:deratable) next unless surface.key?(:type) @@ -273,11 +277,11 @@ def uprate(model = nil, s = {}, argh = {}) next end - coll[id] = {} + lc = lc.get + + coll[id] = {} coll[id][:lc ] = lc coll[id][:s ] = {} - coll[id][:idx ] = surface[:index] - coll[id][:ltp ] = surface[:ltype] coll[id][:hloss] = 0 coll[id][:area ] = 0 coll[id][:film ] = 0 @@ -344,7 +348,7 @@ def uprate(model = nil, s = {}, argh = {}) next if surface[:type ] == typ next if coll[id][:s].key?(nom) - log(WRN, "Cloning '#{nom}' construction - not '#{id}' (#{mth})") + log(INF, "Cloning '#{nom}' construction - not '#{id}' (#{mth})") srf = model.getSurfaceByName(nom) next if srf.empty? @@ -360,11 +364,16 @@ def uprate(model = nil, s = {}, argh = {}) col[:s].values.each do |item| col[:hloss] += item[:h] col[:area ] += item[:a] - col[:fA ] += item[:a] / item[:f] + col[:fA ] += item[:a] / item[:f] unless item[:f] < 0 + end + + if col[:area] < TOL + empty("#{id} area", mth, WRN) + next end # Area-weighted surface air film resistances. - col[:film] = 1 / ( col[:fA] / col[:area] ) + col[:film] = 1 / (col[:fA] / col[:area]) # Fetch required, uprated Uo. u = uo(id, col[:lc], col[:area], col[:film], col[:hloss], g[:ut]) @@ -394,9 +403,11 @@ def uprate(model = nil, s = {}, argh = {}) area = coll.values.sum { |col| col[:area] } uA = coll.values.sum { |col| col[:uA ] } - argh[:wall_uo ] = uA / area if typ == :wall - argh[:roof_uo ] = uA / area if typ == :ceiling - argh[:floor_uo] = uA / area if typ == :floor + if area > TOL + argh[:wall_uo ] = uA / area if typ == :wall + argh[:roof_uo ] = uA / area if typ == :ceiling + argh[:floor_uo] = uA / area if typ == :floor + end end end @@ -455,20 +466,28 @@ def qc33(s = {}, sets = nil, spts = true) ref = 1 / 3.60 if surface[:type] == :wall # Adjust for lower heating setpoint (assumes -25C design conditions). - ref *= 43 / (heating + 25) if heating < 18 && cooling > 40 + if heating > -25 && heating < 18 && cooling > 40 + ref *= 43 / (heating + 25) + end surface[:ref] = ref if surface.key?(:skylights) # loop through subsurfaces ref = 2.85 - ref *= 43 / (heating + 25) if heating < 18 && cooling > 40 + + if heating > -25 && heating < 18 && cooling > 40 + ref *= 43 / (heating + 25) + end surface[:skylights].values.map { |skylight| skylight[:ref] = ref } end if surface.key?(:windows) ref = 2.0 - ref *= 43 / (heating + 25) if heating < 18 && cooling > 40 + + if heating > -25 && heating < 18 && cooling > 40 + ref *= 43 / (heating + 25) + end surface[:windows].values.map { |window| window[:ref] = ref } end @@ -477,7 +496,11 @@ def qc33(s = {}, sets = nil, spts = true) surface[:doors].each do |i, door| ref = 0.9 ref = 2.0 if door.key?(:glazed) && door[:glazed] - ref *= 43 / (heating + 25) if heating < 18 && cooling > 40 + + if heating > -25 && heating < 18 && cooling > 40 + ref *= 43 / (heating + 25) + end + door[:ref] = ref end end diff --git a/spec/tbd_tests_spec.rb b/spec/tbd_tests_spec.rb index e279559..4b04c20 100644 --- a/spec/tbd_tests_spec.rb +++ b/spec/tbd_tests_spec.rb @@ -152,7 +152,6 @@ file = File.join(__dir__, "files/osms/out/seb2.osm") model.save(file, true) - argh = {} argh[:option ] = "(non thermal bridging)" argh[:io_path ] = File.join(__dir__, "../json/tbd_seb_n2.json") @@ -206,6 +205,45 @@ roof_edges.each { |edg| expect(edg[:surfaces].size).to eq(2) } end + it "can uprate selected construction" do + translator = OpenStudio::OSVersion::VersionTranslator.new + TBD.clean! + + name = "Typical Insulated Metal Building Wall R-11.9 1" + file = File.join(__dir__, "files/osms/in/warehouse.osm") + path = OpenStudio::Path.new(file) + model = translator.loadModel(path) + expect(model).to_not be_empty + model = model.get + + lc = model.getLayeredConstructionByName(name) + expect(lc).to_not be_empty + lc = lc.get + expect(lc.getNetArea.round).to eq(126) + + argh = {} + argh[:option ] = "spandrel HP (BETBG)" + argh[:uprate_walls] = true + argh[:wall_option ] = name + argh[:wall_ut ] = 0.210 # NECB CZ7 2017 (RSi 4.76 / R27) + + json = TBD.process(model, argh) + 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(TBD.warn?).to be true + expect(TBD.logs.size).to eq(2) + expect(TBD.logs.first[:message]).to include("Negative ") + expect(TBD.logs.first[:message]).to include(" new Uo' (TBD::uo)") + expect(TBD.logs.last[:message]).to include("Unable to completely uprate ") + expect(argh).to have_key(:wall_uo) + expect(argh).to_not have_key(:roof_uo) + expect(argh[:wall_uo]).to_not be_nil + expect(argh[:wall_uo].round(2)).to eq(UMIN) + end + it "can process JSON surface KHI & PSI entries + building & edge" do translator = OpenStudio::OSVersion::VersionTranslator.new TBD.clean! @@ -2471,7 +2509,7 @@ expect(TBD.unconditioned?(plnum)).to be true expect(TBD.setpoints(plnum)[:heating]).to be_nil expect(TBD.setpoints(plnum)[:cooling]).to be_nil - puts TBD.logs + puts TBD.logs unless TBD.logs.empty? expect(TBD.status).to be_zero argh = { option: "uncompliant (Quebec)" } From fe9c51435a34f454256d52fb5519e518f19ae6da Mon Sep 17 00:00:00 2001 From: brgix Date: Thu, 16 Apr 2026 16:57:38 -0400 Subject: [PATCH 13/14] Adjusts assumed setpoints (UA', qc33) --- lib/measures/tbd/measure.xml | 6 +++--- lib/measures/tbd/resources/ua.rb | 2 +- lib/tbd/ua.rb | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/measures/tbd/measure.xml b/lib/measures/tbd/measure.xml index eaa7479..147783b 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 - 5bc790cf-6b1f-4c00-add7-0741b40fe621 - 2026-04-16T12:02:04Z + bcdf8966-82c9-403c-ad39-1eff3a506da6 + 2026-04-16T20:33:27Z 99772807 TBDMeasure Thermal Bridging and Derating - TBD @@ -541,7 +541,7 @@ ua.rb rb resource - 0ADD60B8 + 60E6C082 utils.rb diff --git a/lib/measures/tbd/resources/ua.rb b/lib/measures/tbd/resources/ua.rb index 0c65f0f..e4904b6 100644 --- a/lib/measures/tbd/resources/ua.rb +++ b/lib/measures/tbd/resources/ua.rb @@ -454,7 +454,7 @@ def qc33(s = {}, sets = nil, spts = true) next unless surface[:deratable] next unless surface.key?(:type) - heating = -50 if spts + heating = -24 if spts cooling = 50 if spts heating = 21 unless spts cooling = 24 unless spts diff --git a/lib/tbd/ua.rb b/lib/tbd/ua.rb index 0c65f0f..e4904b6 100644 --- a/lib/tbd/ua.rb +++ b/lib/tbd/ua.rb @@ -454,7 +454,7 @@ def qc33(s = {}, sets = nil, spts = true) next unless surface[:deratable] next unless surface.key?(:type) - heating = -50 if spts + heating = -24 if spts cooling = 50 if spts heating = 21 unless spts cooling = 24 unless spts From 8b2209be29d92faa44ae011e9525dab58b62137d Mon Sep 17 00:00:00 2001 From: brgix Date: Fri, 17 Apr 2026 07:05:38 -0400 Subject: [PATCH 14/14] Minor setpoint tweaks to qc33 --- lib/measures/tbd/measure.xml | 6 +-- lib/measures/tbd/resources/ua.rb | 65 ++++++++++++++------------------ lib/tbd/ua.rb | 65 ++++++++++++++------------------ 3 files changed, 59 insertions(+), 77 deletions(-) diff --git a/lib/measures/tbd/measure.xml b/lib/measures/tbd/measure.xml index 147783b..b3f705d 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 - bcdf8966-82c9-403c-ad39-1eff3a506da6 - 2026-04-16T20:33:27Z + 4934dc58-443d-436f-9358-2d221b4de65a + 2026-04-17T10:47:02Z 99772807 TBDMeasure Thermal Bridging and Derating - TBD @@ -541,7 +541,7 @@ ua.rb rb resource - 60E6C082 + 0FB3F654 utils.rb diff --git a/lib/measures/tbd/resources/ua.rb b/lib/measures/tbd/resources/ua.rb index e4904b6..994d0b5 100644 --- a/lib/measures/tbd/resources/ua.rb +++ b/lib/measures/tbd/resources/ua.rb @@ -441,65 +441,56 @@ def qc33(s = {}, sets = nil, spts = true) return mismatch("sets", sets, cl1, mth, DBG, false) unless sets.is_a?(cl2) shorts = sets.shorthands("code (Quebec)") - empty = shorts[:has].empty? || shorts[:val].empty? - log(DBG, "Missing QC PSI set for 3.3 UA' tradeoff (#{mth})") if empty - return false if empty - ok = [true, false].include?(spts) - log(DBG, "setpoints must be true or false for 3.3 UA' tradeoff") unless ok - return false unless ok + if shorts[:has].empty? || shorts[:val].empty? + log(DBG, "Missing QC PSI set for 3.3 UA' tradeoff (#{mth})") + return false + end + + unless [true, false].include?(spts) + log(DBG, "setpoints must be true or false for 3.3 UA' tradeoff") + return false + end s.each do |id, surface| next unless surface.key?(:deratable) next unless surface[:deratable] next unless surface.key?(:type) - heating = -24 if spts - cooling = 50 if spts - heating = 21 unless spts - cooling = 24 unless spts - heating = surface[:heating] if surface.key?(:heating) - cooling = surface[:cooling] if surface.key?(:cooling) + htng = spts ? -24 : 21 + clng = spts ? 50 : 24 + htng = surface[:heating] if surface.key?(:heating) + clng = surface[:cooling] if surface.key?(:cooling) - # Start with surface U-factors. - ref = 1 / 5.46 - ref = 1 / 3.60 if surface[:type] == :wall + # Avoid 'divide by zero' case. + htng = -24 if htng < -24 - # Adjust for lower heating setpoint (assumes -25C design conditions). - if heating > -25 && heating < 18 && cooling > 40 - ref *= 43 / (heating + 25) - end + # Start with surface U-factors. Adjust for lower heating setpoint. + # Assumes -25C design conditions. + ref = ( surface[:type] == :wall ) ? (1 / 3.60) : (1 / 5.46) + ref *= 43 / (htng + 25) if htng > -25 && htng < 18 && clng > 40 surface[:ref] = ref - if surface.key?(:skylights) # loop through subsurfaces - ref = 2.85 - - if heating > -25 && heating < 18 && cooling > 40 - ref *= 43 / (heating + 25) - end + # Loop through subsurfaces. + if surface.key?(:skylights) + ref = 2.85 + ref *= 43 / (htng + 25) if htng > -25 && htng < 18 && clng > 40 surface[:skylights].values.map { |skylight| skylight[:ref] = ref } end if surface.key?(:windows) - ref = 2.0 - - if heating > -25 && heating < 18 && cooling > 40 - ref *= 43 / (heating + 25) - end + ref = 2.0 + ref *= 43 / (htng + 25) if htng > -25 && htng < 18 && clng > 40 surface[:windows].values.map { |window| window[:ref] = ref } end if surface.key?(:doors) - surface[:doors].each do |i, door| - ref = 0.9 - ref = 2.0 if door.key?(:glazed) && door[:glazed] - - if heating > -25 && heating < 18 && cooling > 40 - ref *= 43 / (heating + 25) - end + surface[:doors].values.each do |door| + ref = ( door.key?(:glazed) && door[:glazed] ) ? 2.0 : 0.9 + ref *= 43 / (htng + 25) if htng > -25 && htng < 18 && clng > 40 door[:ref] = ref end diff --git a/lib/tbd/ua.rb b/lib/tbd/ua.rb index e4904b6..994d0b5 100644 --- a/lib/tbd/ua.rb +++ b/lib/tbd/ua.rb @@ -441,65 +441,56 @@ def qc33(s = {}, sets = nil, spts = true) return mismatch("sets", sets, cl1, mth, DBG, false) unless sets.is_a?(cl2) shorts = sets.shorthands("code (Quebec)") - empty = shorts[:has].empty? || shorts[:val].empty? - log(DBG, "Missing QC PSI set for 3.3 UA' tradeoff (#{mth})") if empty - return false if empty - ok = [true, false].include?(spts) - log(DBG, "setpoints must be true or false for 3.3 UA' tradeoff") unless ok - return false unless ok + if shorts[:has].empty? || shorts[:val].empty? + log(DBG, "Missing QC PSI set for 3.3 UA' tradeoff (#{mth})") + return false + end + + unless [true, false].include?(spts) + log(DBG, "setpoints must be true or false for 3.3 UA' tradeoff") + return false + end s.each do |id, surface| next unless surface.key?(:deratable) next unless surface[:deratable] next unless surface.key?(:type) - heating = -24 if spts - cooling = 50 if spts - heating = 21 unless spts - cooling = 24 unless spts - heating = surface[:heating] if surface.key?(:heating) - cooling = surface[:cooling] if surface.key?(:cooling) + htng = spts ? -24 : 21 + clng = spts ? 50 : 24 + htng = surface[:heating] if surface.key?(:heating) + clng = surface[:cooling] if surface.key?(:cooling) - # Start with surface U-factors. - ref = 1 / 5.46 - ref = 1 / 3.60 if surface[:type] == :wall + # Avoid 'divide by zero' case. + htng = -24 if htng < -24 - # Adjust for lower heating setpoint (assumes -25C design conditions). - if heating > -25 && heating < 18 && cooling > 40 - ref *= 43 / (heating + 25) - end + # Start with surface U-factors. Adjust for lower heating setpoint. + # Assumes -25C design conditions. + ref = ( surface[:type] == :wall ) ? (1 / 3.60) : (1 / 5.46) + ref *= 43 / (htng + 25) if htng > -25 && htng < 18 && clng > 40 surface[:ref] = ref - if surface.key?(:skylights) # loop through subsurfaces - ref = 2.85 - - if heating > -25 && heating < 18 && cooling > 40 - ref *= 43 / (heating + 25) - end + # Loop through subsurfaces. + if surface.key?(:skylights) + ref = 2.85 + ref *= 43 / (htng + 25) if htng > -25 && htng < 18 && clng > 40 surface[:skylights].values.map { |skylight| skylight[:ref] = ref } end if surface.key?(:windows) - ref = 2.0 - - if heating > -25 && heating < 18 && cooling > 40 - ref *= 43 / (heating + 25) - end + ref = 2.0 + ref *= 43 / (htng + 25) if htng > -25 && htng < 18 && clng > 40 surface[:windows].values.map { |window| window[:ref] = ref } end if surface.key?(:doors) - surface[:doors].each do |i, door| - ref = 0.9 - ref = 2.0 if door.key?(:glazed) && door[:glazed] - - if heating > -25 && heating < 18 && cooling > 40 - ref *= 43 / (heating + 25) - end + surface[:doors].values.each do |door| + ref = ( door.key?(:glazed) && door[:glazed] ) ? 2.0 : 0.9 + ref *= 43 / (htng + 25) if htng > -25 && htng < 18 && clng > 40 door[:ref] = ref end