From 4ee70ee1f2b63cacd5fd249f3766c4c62c4e8a95 Mon Sep 17 00:00:00 2001 From: brgix Date: Tue, 31 Mar 2026 08:48:44 -0400 Subject: [PATCH 01/10] Updates OSut gem files --- Gemfile | 3 ++- tbd_tests.gemspec | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/Gemfile b/Gemfile index 0243d8d..8c2d34c 100644 --- a/Gemfile +++ b/Gemfile @@ -1,5 +1,6 @@ source "https://rubygems.org" -gem "tbd", git: "https://github.com/rd2/tbd", branch: "develop" +gem "osut", git: "https://github.com/rd2/osut", branch: "airfilm" +gem "tbd", git: "https://github.com/rd2/tbd", branch: "airfilm" gemspec diff --git a/tbd_tests.gemspec b/tbd_tests.gemspec index a005917..f637135 100644 --- a/tbd_tests.gemspec +++ b/tbd_tests.gemspec @@ -27,7 +27,8 @@ Gem::Specification.new do |s| s.required_ruby_version = [">= 2.5.0", "< 4"] s.metadata = {} - s.add_development_dependency "tbd", "~> 3.5.2" + s.add_development_dependency "osut", "~> 0.8.3" + s.add_development_dependency "tbd", "~> 3.5.3" s.add_development_dependency "json-schema", "~> 4" s.add_development_dependency "rake", "~> 13.0" s.add_development_dependency "rspec", "~> 3.11" From 95510b9d7f3db73ba548e81951d21aaa405feac8 Mon Sep 17 00:00:00 2001 From: brgix Date: Fri, 3 Apr 2026 09:06:52 -0400 Subject: [PATCH 02/10] Tests OSut 'filmResistances' (+ fixes) --- spec/tbd_tests_spec.rb | 275 +++++++++++++++++++++++------------------ 1 file changed, 152 insertions(+), 123 deletions(-) diff --git a/spec/tbd_tests_spec.rb b/spec/tbd_tests_spec.rb index 710287a..825c7ed 100644 --- a/spec/tbd_tests_spec.rb +++ b/spec/tbd_tests_spec.rb @@ -658,7 +658,7 @@ next if surface[:ground ] next unless surface[:conditioned] - unless surface[:boundary].downcase == "outdoors" + unless surface[:boundary] == "outdoors" next unless surfaces.key?(surface[:boundary]) next if surfaces[surface[:boundary]][:conditioned] end @@ -1426,7 +1426,7 @@ next if holes.key?(i) next if shades.key?(i) - facing = surfaces[i][:boundary].downcase + facing = surfaces[i][:boundary] next unless facing == "othersidecoefficients" s1 = edge[:surfaces][id] @@ -2377,7 +2377,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 @@ -2452,7 +2452,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 @@ -2532,7 +2532,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 @@ -2620,7 +2620,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 @@ -2692,7 +2692,7 @@ # Tracking outdoor-facing office walls. model.getSurfaces.each do |s| next unless s.surfaceType == "Wall" - next unless s.outsideBoundaryCondition == "Outdoors" + next unless s.outsideBoundaryCondition.downcase == "outdoors" id = s.construction.get.nameString str = "Typical Insulated Wood Framed Exterior Wall R-11.24" @@ -2763,6 +2763,7 @@ next unless surface[:space].nameString == "Attic" # Attic is an UNENCLOSED zone - outdoor-facing surfaces are not derated. + expect(surface).to have_key(:filmRSI) expect(surface).to have_key(:conditioned) expect(surface[:conditioned]).to be false expect(surface).to_not have_key(:heatloss) @@ -2772,7 +2773,7 @@ # office spaces) share derated constructions (although inverted). expect(surface).to have_key(:boundary) b = surface[:boundary] - next if b.downcase == "outdoors" + next if b == "outdoors" # TBD/Topolys should be tracking the adjacent CONDITIONED surface. expect(surfaces).to have_key(b) @@ -2818,21 +2819,26 @@ # Comparing derating ratios of constructions. expect(c.layers[1].to_MasslessOpaqueMaterial).to_not be_empty m = c.layers[1].to_MasslessOpaqueMaterial.get + expect(surface[:filmRSI].round(4)).to eq(0.2665) # Before derating. - initial_R = s.filmResistance + # "5/8 in. Gypsum Board" : RSi = 0.0994 m2.K/W + # "Typical Insulation R-35.4 1" : RSi = 6.2348 m2.K/W + # surface air film resistances : RSi = 0.2665 m2.K/W + # ----------------------------- ------------------- + # RSi = 6.6007 m2.K/W + initial_R = surface[:filmRSI] initial_R += 0.0994 initial_R += 6.2348 + expect(initial_R.round(3)).to eq(6.601) # After derating. - derated_R = s.filmResistance + derated_R = surface[:filmRSI] derated_R += 0.0994 derated_R += m.thermalResistance ratio = -(initial_R - derated_R) * 100 / initial_R expect(ratio).to be_within(1).of(surfaces[b][:ratio]) - # "5/8 in. Gypsum Board" : RSi = 0,0994 m2.K/W - # "Typical Insulation R-35.4 1" : RSi = 6,2348 m2.K/W end surfaces.each do |id, surface| @@ -2844,7 +2850,7 @@ expect(surface[:heatloss]).to be_within(0.001).of(0) expect(surface).to_not have_key(:ratio) expect(surface).to have_key(:u) - expect(surface[:u]).to be_within(0.001).of(0.153) + expect(surface[:u]).to be_within(0.001).of(0.152) next end @@ -2967,6 +2973,7 @@ next unless surface[:space].nameString == "Attic" # Attic is an UNENCLOSED zone - outdoor-facing surfaces are not derated. + expect(surface).to have_key(:filmRSI) expect(surface).to have_key(:conditioned) expect(surface[:conditioned]).to be false expect(surface).to_not have_key(:heatloss) @@ -2974,7 +2981,7 @@ expect(surface).to have_key(:boundary) b = surface[:boundary] - next if b == "Outdoors" + next if b == "outdoors" expect(surfaces).to have_key(b) expect(surfaces[b]).to have_key(:conditioned) @@ -2989,13 +2996,12 @@ s = model.getSurfaceByName(id) expect(s).to_not be_empty s = s.get - expect(s.nameString).to eq(id) expect(s.surfaceType).to eq("Floor") expect(s.isConstructionDefaulted).to be false c = s.construction.get.to_LayeredConstruction expect(c).to_not be_empty c = c.get - next unless c.nameString == "Attic_floor_perimeter_south floor" + next unless c.nameString.include?("Attic_floor_perimeter_south") expect(c.nameString).to include("c tbd") expect(c.layers.size).to eq(2) @@ -3003,82 +3009,97 @@ expect(c.layers[1].nameString).to include("m tbd") expect(c.layers[1].to_MasslessOpaqueMaterial).to_not be_empty m = c.layers[1].to_MasslessOpaqueMaterial.get + expect(surface[:filmRSI].round(4)).to eq(0.2665) # Before derating. - initial_R = s.filmResistance + # "5/8 in. Gypsum Board" : RSi = 0.0994 m2.K/W + # "Typical Insulation R-35.4 1" : RSi = 6.2348 m2.K/W + # surface air film resistances : RSi = 0.2665 m2.K/W + # ----------------------------- ------------------- + # RSi = 6.6007 m2.K/W + initial_R = surface[:filmRSI] initial_R += 0.0994 initial_R += 6.2348 + expect(initial_R.round(3)).to eq(6.601) # After derating. - derated_R = s.filmResistance + derated_R = surface[:filmRSI] derated_R += 0.0994 derated_R += m.thermalResistance + expect(derated_R.round(3)).to eq(3.319) ratio = -(initial_R - derated_R) * 100 / initial_R + expect(ratio.round(2)).to eq(-49.72) expect(ratio).to be_within(1).of(surfaces[b][:ratio]) - # "5/8 in. Gypsum Board" : RSi = 0,0994 m2.K/W - # "Typical Insulation R-35.4 1" : RSi = 6,2348 m2.K/W + end - surfaces.each do |id, surface| - next unless surface.key?(:edges) + surfaces.each do |id, surface| + next unless surface.key?(:edges) - expect(surface).to have_key(:heatloss) - expect(surface).to have_key(:ratio) - h = surface[:heatloss] - s = model.getSurfaceByName(id) - expect(s).to_not be_empty - s = s.get - expect(s.nameString).to eq(id) - expect(s.isConstructionDefaulted).to be false - expect(s.construction.get.nameString).to include(" tbd") - next unless s.surfaceType == "Wall" + expect(surface).to have_key(:heatloss) - # Testing outdoor-facing walls. - expect(h).to be_within(TOL).of(51.17) if id.include?("_1_") # South - expect(h).to be_within(TOL).of(33.08) if id.include?("_2_") # East - expect(h).to be_within(TOL).of(48.32) if id.include?("_3_") # North - expect(h).to be_within(TOL).of(33.08) if id.include?("_4_") # West + if id == "Core_ZN_ceiling" + expect(surface[:heatloss]).to be_within(0.001).of(0) + expect(surface).to_not have_key(:ratio) + expect(surface).to have_key(:u) + expect(surface[:u]).to be_within(0.001).of(0.152) + next + end - 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.layers.size).to eq(4) - expect(c.layers[2].nameString).to include("m tbd") - next unless id.include?("_1_") # South + expect(surface).to have_key(:ratio) + h = surface[:heatloss] + s = model.getSurfaceByName(id) + expect(s).to_not be_empty + s = s.get + expect(s.nameString).to eq(id) + expect(s.isConstructionDefaulted).to be false + expect(s.construction.get.nameString).to include(" tbd") + next unless s.surfaceType == "Wall" + + expect(h).to be_within(TOL).of(51.17) if id.include?("_1_") # South + expect(h).to be_within(TOL).of(33.08) if id.include?("_2_") # East + expect(h).to be_within(TOL).of(48.32) if id.include?("_3_") # North + expect(h).to be_within(TOL).of(33.08) if id.include?("_4_") # West - l_fen = 0 - l_head = 0 - l_sill = 0 - l_jamb = 0 - l_grade = 0 - l_parapet = 0 - l_corner = 0 + 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.layers.size).to eq(4) + expect(c.layers[2].nameString).to include("m tbd") + next unless id.include?("_1_") # South - surface[:edges].values.each do |edge| - l_fen += edge[:length] if edge[:type] == :fenestration - l_head += edge[:length] if edge[:type] == :head - l_sill += edge[:length] if edge[:type] == :sill - l_jamb += edge[:length] if edge[:type] == :jamb - l_grade += edge[:length] if edge[:type] == :grade - l_grade += edge[:length] if edge[:type] == :gradeconcave - l_grade += edge[:length] if edge[:type] == :gradeconvex - l_parapet += edge[:length] if edge[:type] == :parapet - l_parapet += edge[:length] if edge[:type] == :parapetconcave - l_parapet += edge[:length] if edge[:type] == :parapetconvex - l_corner += edge[:length] if edge[:type] == :cornerconcave - l_corner += edge[:length] if edge[:type] == :cornerconvex - end + l_fen = 0 + l_head = 0 + l_sill = 0 + l_jamb = 0 + l_grade = 0 + l_parapet = 0 + l_corner = 0 - expect(l_fen ).to be_within(TOL).of( 0.00) - expect(l_head ).to be_within(TOL).of(46.35) - expect(l_sill ).to be_within(TOL).of(46.35) - expect(l_jamb ).to be_within(TOL).of(46.35) - expect(l_grade ).to be_within(TOL).of(27.69) - expect(l_parapet).to be_within(TOL).of(27.69) - expect(l_corner ).to be_within(TOL).of( 6.10) + surface[:edges].values.each do |edge| + l_fen += edge[:length] if edge[:type] == :fenestration + l_head += edge[:length] if edge[:type] == :head + l_sill += edge[:length] if edge[:type] == :sill + l_jamb += edge[:length] if edge[:type] == :jamb + l_grade += edge[:length] if edge[:type] == :grade + l_grade += edge[:length] if edge[:type] == :gradeconcave + l_grade += edge[:length] if edge[:type] == :gradeconvex + l_parapet += edge[:length] if edge[:type] == :parapet + l_parapet += edge[:length] if edge[:type] == :parapetconcave + l_parapet += edge[:length] if edge[:type] == :parapetconvex + l_corner += edge[:length] if edge[:type] == :cornerconcave + l_corner += edge[:length] if edge[:type] == :cornerconvex end + + expect(l_fen ).to be_within(TOL).of( 0.00) + expect(l_head ).to be_within(TOL).of(12.81) + expect(l_sill ).to be_within(TOL).of(10.98) + expect(l_jamb ).to be_within(TOL).of(22.56) + expect(l_grade ).to be_within(TOL).of(27.69) + expect(l_parapet).to be_within(TOL).of(27.69) + expect(l_corner ).to be_within(TOL).of( 6.10) end end @@ -3093,7 +3114,7 @@ model = model.get model.getSurfaces.each do |s| - next unless s.outsideBoundaryCondition == "Outdoors" + next unless s.outsideBoundaryCondition.downcase == "outdoors" expect(s.space).to_not be_empty expect(s.isConstructionDefaulted).to be true @@ -3198,7 +3219,7 @@ expect(surface[:ratio]).to be_within(0.2).of(-15.6) if id == ids[:c] expect(surface[:ratio]).to be_within(0.2).of(- 7.3) if id == ids[:i] else - expect(surface[:boundary].downcase).to_not eq("outdoors") + expect(surface[:boundary]).to_not eq("outdoors") end end end @@ -3312,7 +3333,7 @@ # puts "#{name} RSi derated by #{ratio}%" expect(surface[:ratio]).to be_within(0.2).of(-46.0) if id == ids[:b] else - expect(surface[:boundary].downcase).to_not eq("outdoors") + expect(surface[:boundary]).to_not eq("outdoors") end end @@ -3383,7 +3404,7 @@ # puts "#{name} RSi derated by #{ratio}%" expect(surface[:ratio]).to be_within(0.2).of(-46.0) if id == ids[:b] else - expect(surface[:boundary].downcase).to_not eq("outdoors") + expect(surface[:boundary]).to_not eq("outdoors") end end @@ -3511,7 +3532,7 @@ # puts "#{name} RSi derated by #{ratio}%" expect(surface[:ratio]).to be_within(0.2).of(-41.9) if id == ids[:b] else - expect(surface[:boundary].downcase).to_not eq("outdoors") + expect(surface[:boundary]).to_not eq("outdoors") end end @@ -3548,7 +3569,7 @@ # puts "#{name} RSi derated by #{ratio}%" expect(surface[:ratio]).to be_within(0.2).of(-41.9) if id == ids[:b] else - expect(surface[:boundary].downcase).to_not eq("outdoors") + expect(surface[:boundary]).to_not eq("outdoors") end end @@ -3600,7 +3621,7 @@ end surfaces.each do |id, surface| - next unless surface[:boundary].downcase == "outdoors" + next unless surface[:boundary] == "outdoors" next unless surface.key?(:ratio) expect(surface).to have_key(:heatloss) @@ -3706,7 +3727,7 @@ expect(surface).to have_key(:story) expect(surface).to have_key(:boundary) - expect(surface[:boundary]).to eq("Outdoors") + expect(surface[:boundary]).to eq("outdoors") nom = surface[:story].nameString expect(stories).to include(nom) @@ -3807,7 +3828,7 @@ id = c.nameString name = s.nameString - if s.outsideBoundaryCondition == "Outdoors" + if s.outsideBoundaryCondition.downcase == "outdoors" expect(c.layers.size).to eq(4) expect(c.layers[2].to_StandardOpaqueMaterial).to_not be_empty m = c.layers[2].to_StandardOpaqueMaterial.get @@ -4095,11 +4116,13 @@ expect(c).to_not be_empty c = c.get i = 0 - i = 2 if s.outsideBoundaryCondition == "Outdoors" + i = 2 if s.outsideBoundaryCondition.downcase == "outdoors" expect(c.layers[i].nameString).to include("m tbd") end surfaces.each do |id, surface| + expect(surface).to have_key(:filmRSI) + if surface.key?(:ratio) expect(surface[:ratio]).to be_within(0.1).of(-36.74) if id == ids[:a] expect(surface[:ratio]).to be_within(0.1).of(-34.61) if id == ids[:b] @@ -4135,14 +4158,14 @@ expect(c.layers[2].to_StandardOpaqueMaterial).to_not be_empty m = c.layers[2].to_StandardOpaqueMaterial.get - initial_R = s.filmResistance + 2.4674 - derated_R = s.filmResistance + 0.9931 + initial_R = surface[:filmRSI] + 2.4674 + derated_R = surface[:filmRSI] + 0.9931 derated_R += m.thickness / m.thermalConductivity ratio = -(initial_R - derated_R) * 100 / initial_R expect(ratio).to be_within(1).of(surfaces[id][:ratio]) else - if surface[:boundary].downcase == "outdoors" + if surface[:boundary] == "outdoors" expect(surface[:conditioned]).to be false end end @@ -4550,7 +4573,7 @@ expect(surface[:ratio]).to be_within(0.2).of(-19.02) if id == ids[:k] expect(surface[:ratio]).to be_within(0.2).of(-15.09) if id == ids[:l] else - expect(surface[:boundary].downcase).to_not eq("outdoors") + expect(surface[:boundary]).to_not eq("outdoors") end end end @@ -4738,11 +4761,13 @@ expect(c).to_not be_empty c = c.get i = 0 - i = 2 if s.outsideBoundaryCondition == "Outdoors" + i = 2 if s.outsideBoundaryCondition.downcase == "outdoors" expect(c.layers[i].nameString).to include("m tbd") end surfaces.each do |id, surface| + expect(surface).to have_key(:filmRSI) + if surface.key?(:ratio) expect(surface[:ratio]).to be_within(0.1).of(-28.93) if id == ids[:a] expect(surface[:ratio]).to be_within(0.1).of(-26.61) if id == ids[:b] @@ -4779,14 +4804,14 @@ expect(c.layers[2].to_StandardOpaqueMaterial).to_not be_empty m = c.layers[2].to_StandardOpaqueMaterial.get - initial_R = s.filmResistance + 2.4674 - derated_R = s.filmResistance + 0.9931 + initial_R = surface[:filmRSI] + 2.4674 + derated_R = surface[:filmRSI] + 0.9931 derated_R += m.thickness / m.thermalConductivity ratio = -(initial_R - derated_R) * 100 / initial_R expect(ratio).to be_within(1).of(surfaces[id][:ratio]) else - if surface[:boundary].downcase == "outdoors" + if surface[:boundary] == "outdoors" expect(surface[:conditioned]).to be false end end @@ -4899,11 +4924,13 @@ expect(c).to_not be_empty c = c.get i = 0 - i = 2 if s.outsideBoundaryCondition == "Outdoors" + i = 2 if s.outsideBoundaryCondition.downcase == "outdoors" expect(c.layers[i].nameString).to include("m tbd") end surfaces.each do |id, surface| + expect(surface).to have_key(:filmRSI) + if surface.key?(:ratio) expect(surface[:ratio]).to be_within(0.1).of(-28.93) if id == ids[:a] expect(surface[:ratio]).to be_within(0.1).of(-26.61) if id == ids[:b] @@ -4940,14 +4967,14 @@ expect(c.layers[2].to_StandardOpaqueMaterial).to_not be_empty m = c.layers[2].to_StandardOpaqueMaterial.get - initial_R = s.filmResistance + 2.4674 - derated_R = s.filmResistance + 0.9931 + initial_R = surface[:filmRSI] + 2.4674 + derated_R = surface[:filmRSI] + 0.9931 derated_R += m.thickness / m.thermalConductivity ratio = -(initial_R - derated_R) * 100 / initial_R expect(ratio).to be_within(1).of(surfaces[id][:ratio]) else - if surface[:boundary].downcase == "outdoors" + if surface[:boundary] == "outdoors" expect(surface[:conditioned]).to be false end end @@ -5060,11 +5087,13 @@ expect(c).to_not be_empty c = c.get i = 0 - i = 2 if s.outsideBoundaryCondition == "Outdoors" + i = 2 if s.outsideBoundaryCondition.downcase == "outdoors" expect(c.layers[i].nameString).to include("m tbd") end surfaces.each do |id, surface| + expect(surface).to have_key(:filmRSI) + if surface.key?(:ratio) # ratio = format "%3.1f", surface[:ratio] # name = id.rjust(15, " ") @@ -5104,14 +5133,14 @@ expect(c.layers[2].to_StandardOpaqueMaterial).to_not be_empty m = c.layers[2].to_StandardOpaqueMaterial.get - initial_R = s.filmResistance + 2.4674 - derated_R = s.filmResistance + 0.9931 + initial_R = surface[:filmRSI] + 2.4674 + derated_R = surface[:filmRSI] + 0.9931 derated_R += m.thickness / m.thermalConductivity ratio = -(initial_R - derated_R) * 100 / initial_R expect(ratio).to be_within(1).of(surfaces[id][:ratio]) else - if surface[:boundary].downcase == "outdoors" + if surface[:boundary] == "outdoors" expect(surface[:conditioned]).to be false end end @@ -5715,7 +5744,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 @@ -5753,7 +5782,7 @@ # As above, yet the KHI points are now set @0.5 W/K per m (instead of 0) 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 == "Entryway Wall 5" next unless id == "Entryway Wall 5" @@ -5809,7 +5838,7 @@ # As above, with a "good" surface PSI set 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 == "Entryway Wall 5" next unless id == "Entryway Wall 5" @@ -5875,7 +5904,7 @@ # between :corner to :fenestration (or vice versa) for corner windows. surfaces.each do |id, surface| walls = ["Entryway Wall 5", "Entryway Wall 6", "Entryway Wall 4"] - next unless surface[:boundary].downcase == "outdoors" + next unless surface[:boundary] == "outdoors" expect(surface).to have_key(:ratio) if walls.include?(id) expect(surface).to_not have_key(:ratio) unless walls.include?(id) @@ -5933,7 +5962,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) @@ -6337,7 +6366,7 @@ end surfaces.each do |id, surface| - next unless surface[:boundary].downcase == "outdoors" + next unless surface[:boundary] == "outdoors" next unless surface.key?(:ratio) expect(surface).to have_key(:heatloss) @@ -6450,7 +6479,7 @@ expect(surface).to have_key(:edges) expect(surface).to have_key(:story) expect(surface).to have_key(:boundary) - expect(surface[:boundary]).to eq("Outdoors") + expect(surface[:boundary]).to eq("outdoors") nom = surface[:story].nameString expect(stories).to include(nom) expect(nom).to eq(stories[0]) if id.include?("g ") @@ -6649,7 +6678,7 @@ expect(c).to_not be_empty c = c.get i = 0 - i = 2 if s.outsideBoundaryCondition == "Outdoors" + i = 2 if s.outsideBoundaryCondition.downcase == "outdoors" expect(c.layers[i].nameString).to include("m tbd") end end @@ -6700,7 +6729,7 @@ next unless surfaces[id][:space].nameString == "Attic" expect(surfaces[id][:conditioned]).to be false - next if surfaces[id][:boundary] == "Outdoors" + next if surfaces[id][:boundary] == "outdoors" expect(s.adjacentSurface).to_not be_empty adjacent = s.adjacentSurface.get.nameString @@ -6713,7 +6742,7 @@ # Check derating of ceilings (below attic). surfaces.each do |id, surface| next unless surface.key?(:ratio) - next if surface[:boundary].downcase == "outdoors" + next if surface[:boundary] == "outdoors" expect(surface).to have_key(:heatloss) expect(surface[:heatloss].abs).to be > 0 @@ -6724,7 +6753,7 @@ # Check derating of outdoor-facing walls. surfaces.each do |id, surface| next unless surface.key?(:ratio) - next unless surface[:boundary].downcase == "outdoors" + next unless surface[:boundary] == "outdoors" expect(surface).to have_key(:heatloss) expect(surface[:heatloss].abs).to be > 0 @@ -6783,7 +6812,7 @@ }.freeze surfaces.each do |id, surface| - next unless surface[:boundary].downcase == "outdoors" + next unless surface[:boundary] == "outdoors" next unless surface.key?(:ratio) expect(surface).to have_key(:heatloss) @@ -7122,7 +7151,7 @@ t11 = :rimjoist surfaces.each do |id, surface| - next unless surface[:boundary].downcase == "outdoors" + next unless surface[:boundary] == "outdoors" next unless surface.key?(:ratio) expect(surface).to have_key(:heatloss) @@ -9647,7 +9676,7 @@ surfaces.each do |id, surface| next unless surface.key?(:boundary) - next unless surface[:boundary] == "Outdoors" + next unless surface[:boundary] == "outdoors" next unless surface.key?(:type) next unless surface[:type] == :wall next unless surface.key?(:construction) @@ -10005,7 +10034,7 @@ next unless surface.key?(:heatloss) next unless surface.key?(:net) next unless surface.key?(:type) - next unless surface[:boundary] == "Outdoors" + next unless surface[:boundary] == "outdoors" next unless surface[:type ] == :wall hloss += surface[:heatloss] @@ -10134,7 +10163,7 @@ model.getSurfaces.each do |s| next unless s.surfaceType == "Wall" - next unless s.outsideBoundaryCondition == "Outdoors" + next unless s.outsideBoundaryCondition.downcase == "outdoors" walls << s.nameString c = s.construction @@ -10320,7 +10349,7 @@ model.getSurfaces.each do |s| next unless s.surfaceType == "Wall" - next unless s.outsideBoundaryCondition == "Outdoors" + next unless s.outsideBoundaryCondition.downcase == "outdoors" walls << s.nameString c = s.construction @@ -12349,7 +12378,8 @@ 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) + f = TBD.filmResistances(:ceiling, s.tilt) + expect(TBD.rsi(c, f)).to be_within(TOL).of(6.44) construction = c if construction.nil? expect(c).to eq(construction) @@ -12441,7 +12471,7 @@ surfaces.each do |nom, surface| expect(surface).to be_a(Hash) - + expect(surface).to have_key(:filmRSI) expect(surface).to have_key(:conditioned) expect(surface).to have_key(:deratable) expect(surface).to have_key(:construction) @@ -12471,7 +12501,7 @@ 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 expect(ua / a).to be_within(TOL).of(argh[:roof_ut]) @@ -12672,7 +12702,7 @@ model.getSurfaces.each do |s| next unless s.surfaceType == "RoofCeiling" - next unless s.outsideBoundaryCondition == "Outdoors" + next unless s.outsideBoundaryCondition.downcase == "outdoors" roofs << s.nameString c = s.construction @@ -12739,6 +12769,7 @@ surfaces.each do |nom, surface| expect(surface).to be_a(Hash) + expect(surface).to have_key(:filmRSI) expect(surface).to have_key(:conditioned) expect(surface).to have_key(:deratable) expect(surface).to have_key(:construction) @@ -12772,7 +12803,7 @@ 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 expect(ua / a).to be_within(TOL).of(argh[:roof_ut]) @@ -13203,8 +13234,6 @@ TBD.logs.each { |log| expect(log[:message]).to include(msg) } surfaces.values.each do |s| - # puts s.keys - # puts expect(s).to_not have_key(:kiva) end From 510a65267ad78147ba5f5774840c901ccda5da5a Mon Sep 17 00:00:00 2001 From: brgix Date: Fri, 10 Apr 2026 14:38:33 -0400 Subject: [PATCH 03/10] Syncs with latest TBD v360 changes --- lib/tbd_tests/version.rb | 2 +- spec/tbd_tests_spec.rb | 937 ++++++++++----------------------------- 2 files changed, 242 insertions(+), 697 deletions(-) diff --git a/lib/tbd_tests/version.rb b/lib/tbd_tests/version.rb index f7d205e..b02455c 100644 --- a/lib/tbd_tests/version.rb +++ b/lib/tbd_tests/version.rb @@ -29,5 +29,5 @@ # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. module TBD_Tests - VERSION = "0.3.2".freeze + VERSION = "0.4.0".freeze end diff --git a/spec/tbd_tests_spec.rb b/spec/tbd_tests_spec.rb index 825c7ed..1cb9994 100644 --- a/spec/tbd_tests_spec.rb +++ b/spec/tbd_tests_spec.rb @@ -9174,126 +9174,66 @@ model = translator.loadModel(path) expect(model).to_not be_empty model = model.get - - # Mimics measure. - walls = {c: {}, dft: "ALL wall constructions" } - roofs = {c: {}, dft: "ALL roof constructions" } - flors = {c: {}, dft: "ALL floor constructions"} - - walls[:c][walls[:dft]] = {a: 100000000000000} - roofs[:c][roofs[:dft]] = {a: 100000000000000} - flors[:c][flors[:dft]] = {a: 100000000000000} - - walls[:chx] = OpenStudio::StringVector.new - roofs[:chx] = OpenStudio::StringVector.new - flors[:chx] = OpenStudio::StringVector.new + rf1 = "Typical Insulated Metal Building Roof R-10.31 1" + rf2 = "Typical Insulated Metal Building Roof R-18.18" model.getSurfaces.each do |s| - type = s.surfaceType.downcase - next unless ["wall", "roofceiling", "floor"].include?(type) + next unless s.surfaceType.downcase == "roofceiling" next unless s.outsideBoundaryCondition.downcase == "outdoors" next if s.construction.empty? next if s.construction.get.to_LayeredConstruction.empty? - lc = s.construction.get.to_LayeredConstruction.get - id = lc.nameString - next if walls[:c].key?(id) - next if roofs[:c].key?(id) - next if flors[:c].key?(id) - - a = lc.getNetArea - # One challenge of the uprate approach concerns OpenStudio-reported - # surface film resistances, which factor-in the slope of the surface and - # surface emittances. As the uprate approach relies on user-defined Ut - # factors (inputs, as targets to meet), it also considers surface film - # resistances. In the schematic cross-section below, let's postulate that - # each slope has a unique pitch: 50deg (s1), 0deg (s2), & 60dge (s3). All - # three surfaces reference the same construction. - # - # s2 - # _____ - # / \ - # s1 / \ s3 - # / \ - # - # For highly-reflective interior finishes (think of Bruce Lee in Enter - # the Dragon), the difference here in reported RSi could reach 0.1 m2.K/W - # or R0.6. That's a 1% to 3% difference for a well-insulated construction. - # This may seem significant, but the impact on energy simulation results - # should be barely noticeable. However, these discrepancies could become - # an irritant when processing an OpenStudio model for code compliance - # purposes. For clear-field (Uo) calculations, a simple solution is ensure - # that the (common) layered construction meets minimal code requirements - # for the surface with the lowest film resistance, here s2. Thus surfaces - # s1 & s3 will slightly overshoot the Uo target. - # - # For Ut calculations (which factor-in major thermal bridging), this is - # not as straightforward as adjusting the construction layers by hand. Yet - # conceptually, the approach here remains similar: for a selected - # construction shared by more than one surface, the considered film - # resistance will be that of the worst case encountered. The resulting Uo - # for that uprated construction might be slightly lower (i.e., better - # performing) than expected in some circumstances. - f = s.filmResistance - - case type - when "wall" - walls[:c][id] = {a: a, lc: lc} - walls[:c][id][:f] = f unless walls[:c][id].key?(:f) - walls[:c][id][:f] = f if walls[:c][id][:f] > f - when "roofceiling" - roofs[:c][id] = {a: a, lc: lc} - roofs[:c][id][:f] = f unless roofs[:c][id].key?(:f) - roofs[:c][id][:f] = f if roofs[:c][id][:f] > f - else - flors[:c][id] = {a: a, lc: lc} - flors[:c][id][:f] = f unless flors[:c][id].key?(:f) - flors[:c][id][:f] = f if flors[:c][id][:f] > f - end - end - - walls[:c] = walls[:c].sort_by{ |k,v| v[:a] }.reverse!.to_h - roofs[:c] = roofs[:c].sort_by{ |k,v| v[:a] }.reverse!.to_h - flors[:c] = flors[:c].sort_by{ |k,v| v[:a] }.reverse!.to_h - - walls[:c][walls[:dft]][:a] = 0 - roofs[:c][roofs[:dft]][:a] = 0 - flors[:c][flors[:dft]][:a] = 0 - - walls[:c].keys.each { |id| walls[:chx] << id } - roofs[:c].keys.each { |id| roofs[:chx] << id } - flors[:c].keys.each { |id| flors[:chx] << id } - - expect(roofs[:c].size).to eq(3) - rf1 = "Typical Insulated Metal Building Roof R-10.31 1" - rf2 = "Typical Insulated Metal Building Roof R-18.18" - expect(roofs[:c].keys[0]).to eq("ALL roof constructions") - expect(roofs[:c]["ALL roof constructions"][:a]).to be_within(TOL).of(0) - roof1 = roofs[:c].values[1] - roof2 = roofs[:c].values[2] - expect(roof1[:a] > roof2[:a]).to be true - expect(roof1[:f]).to be_within(TOL).of(roof2[:f]) - expect(roof1[:f]).to be_within(TOL).of(0.1360) - expect(1/TBD.rsi(roof1[:lc], roof1[:f])).to be_within(TOL).of(0.5512) # R10 - expect(1/TBD.rsi(roof2[:lc], roof2[:f])).to be_within(TOL).of(0.3124) # R18 - - # Deeper dive into rf1 (more prevalent). - targeted = model.getConstructionByName(rf1) - expect(targeted).to_not be_empty - targeted = targeted.get - expect(targeted.to_LayeredConstruction).to_not be_empty - targeted = targeted.to_LayeredConstruction.get - expect(targeted.is_a?(OpenStudio::Model::LayeredConstruction)).to be true - expect(targeted.layers.size).to eq(2) - - targeted.layers.each do |layer| - next unless layer.nameString == "Typical Insulation R-9.53 1" - expect(layer.to_MasslessOpaqueMaterial).to_not be_empty - layer = layer.to_MasslessOpaqueMaterial.get - expect(layer.thermalResistance).to be_within(TOL).of(1.68) # m2.K/W (R9.5) - end + lc = s.construction.get.to_LayeredConstruction.get + id = lc.nameString + flm = s.filmResistance + expect([rf1, rf2]).to include(id) + expect(flm.round(4)).to eq(0.1360) + expect(TBD.rsi(lc, flm).round(3)).to eq(1.814) if id == rf1 # R10 + expect(TBD.rsi(lc, flm).round(3)).to eq(3.201) if id == rf2 # R18 + end + + # One challenge of the uprating approach concerns OpenStudio-reported + # surface film resistances, which factor-in the slope of the surface and + # surface emittances. As the uprate approach relies on user-defined Ut + # factors (inputs, as targets to meet), it also considers surface film + # resistances. In the schematic cross-section below, let's postulate that + # each slope has a unique pitch: 50deg (s1), 0deg (s2), & 60dge (s3). All + # three surfaces reference the same construction. + # + # s2 + # _____ + # / \ + # s1 / \ s3 + # / \ + # + # For highly-reflective interior finishes (think of Bruce Lee in Enter the + # Dragon), the difference here in reported RSi could reach 0.1 m2.K/W or + # R0.6. That's a 1% to 3% difference for a well-insulated construction. This + # may seem significant at first, but the impact on energy simulation results + # should barely be noticeable for well-insulated constructions. Yet such + # discrepancies can become an irritant when processing an OpenStudio model + # for code compliance purposes. This is more challenging when some envelope + # surfaces are INTERZONE (e.g. insulated attic floor). + # + # When uprating clear-field (Uo) calculations, prior TBD versions ensured + # that the shared layered construction met the minimal code requirements + # for the surface with the lowest surface air film resistance, here s2. + # Surfaces s1 & s3 would slightly overshoot the uprated Uo target. + # + # The v3.6 fix now averages out surface air film resistances, as follows: + # + # area-weighted filmRSI = 1 / ( ∑ ( 1/filmRSIi • AREAi ) / AREAt ) + # + # Relying on an area-weighted average of surface air film resistances, some + # surface will report final (derated) Ut values slightly below target, + # others slightly above. Yet the area-weighted average (UA-based) should + # match the code-required Ut requirement. + # + # The other v3.6 change is maintaining user-assigned constructions (i.e. + # not replacing them with a single, predominant roof or wall construction). + # Each construction is certainly uprated, then derated. Yet the original + # user-defined, non-insulating layers are maintained as is. - # argh[:roof_option ] = "Typical Insulated Metal Building Roof R-10.31 1" argh = {} argh[:roof_option ] = "ALL roof constructions" argh[:option ] = "poor (BETBG)" @@ -9351,8 +9291,15 @@ fine_insulation = fine_insulation.get bulk_insulation_r = bulk_insulation.thermalResistance fine_insulation_r = fine_insulation.thermalResistance - expect(bulk_insulation_r).to be_within(TOL).of(7.307) # once derated - expect(fine_insulation_r).to be_within(TOL).of(6.695) # once derated + expect(bulk_insulation_r.round(3)).to eq(7.110) # once derated + expect(fine_insulation_r.round(3)).to eq(7.110) # once derated + + # Both constructions are uprated, then derated to meet the same NECB target. + rsi_bulk = TBD.rsi(bulk_construction, bulk_roof.filmResistance) + rsi_fine = TBD.rsi(fine_construction, fine_roof.filmResistance) + usi_fine = 1 / rsi_fine + expect(rsi_bulk.round(3)).to eq(rsi_fine.round(3)) + expect(usi_fine.round(3)).to eq(argh[:roof_ut]) # TBD objects. expect(surfaces).to have_key(bulk) @@ -9362,24 +9309,26 @@ expect(surfaces[bulk]).to have_key(:net) expect(surfaces[fine]).to have_key(:net) - expect(surfaces[bulk][:heatloss]).to be_within(TOL).of(161.02) - expect(surfaces[fine][:heatloss]).to be_within(TOL).of( 87.16) - expect(surfaces[bulk][:net ]).to be_within(TOL).of(3157.28) - expect(surfaces[fine][:net ]).to be_within(TOL).of(1372.60) + expect(surfaces[bulk][:heatloss].round(2)).to eq(161.02) + expect(surfaces[fine][:heatloss].round(2)).to eq( 87.16) + expect(surfaces[bulk][:net ].round(2)).to eq(3157.28) + expect(surfaces[fine][:net ].round(2)).to eq(1372.60) heatloss = surfaces[bulk][:heatloss] + surfaces[fine][:heatloss] area = surfaces[bulk][:net ] + surfaces[fine][:net ] - expect(heatloss).to be_within(TOL).of( 248.19) - expect(area ).to be_within(TOL).of(4529.88) + expect(heatloss.round(2)).to eq( 248.19) + expect( area.round(2)).to eq(4529.88) + # The TBD data model tracks the initially-uprated constructions. expect(surfaces[bulk]).to have_key(:construction) # not yet derated expect(surfaces[fine]).to have_key(:construction) expect(surfaces[bulk][:construction].nameString).to eq(rf1) - expect(surfaces[fine][:construction].nameString).to eq(rf1) # no longer rf2 + expect(surfaces[fine][:construction].nameString).to eq(rf2) - uprated = model.getConstructionByName(rf1) # not yet derated + # The initially-uprated roof construction is maintained in the model. + uprated = model.getConstructionByName(rf1) expect(uprated).to_not be_empty uprated = uprated.get expect(uprated.to_LayeredConstruction).to_not be_empty @@ -9387,53 +9336,14 @@ expect(uprated.is_a?(OpenStudio::Model::LayeredConstruction)).to be true expect(uprated.layers.size).to eq(2) - uprated_layer_r = 0 uprated.layers.each do |layer| next unless layer.nameString.include?(" uprated") expect(layer.to_MasslessOpaqueMaterial).to_not be_empty - layer = layer.to_MasslessOpaqueMaterial.get - uprated_layer_r = layer.thermalResistance - expect(layer.thermalResistance).to be_within(TOL).of(11.65) # m2.K/W (R66) - end - - rt = TBD.rsi(uprated, roof1[:f]) - expect(1/rt).to be_within(TOL).of(0.0849) # R67 (with surface films) - - # Bulk storage roof demonstration. - u = surfaces[bulk][:heatloss] / surfaces[bulk][:net] - expect(u).to be_within(TOL).of(0.051) # W/m2.K - - de_u = 1 / uprated_layer_r + u - de_r = 1 / de_u - bulk_r = de_r + roof1[:f] - bulk_u = 1 / bulk_r - expect(de_u).to be_within(TOL).of(0.137) # bit below required Ut of 0.138 - expect(de_r).to be_within(TOL).of(bulk_insulation_r) # 7.307, not 11.65 - ratio = -(uprated_layer_r - de_r) * 100 / (uprated_layer_r + roof1[:f]) - expect(ratio).to be_within(TOL).of(-36.84) - expect(surfaces[bulk]).to have_key(:ratio) - expect(surfaces[bulk][:ratio]).to be_within(TOL).of(ratio) - - # Fine storage roof demonstration. - u = surfaces[fine][:heatloss] / surfaces[fine][:net] - expect(u).to be_within(TOL).of(0.063) # W/m2.K - - de_u = 1 / uprated_layer_r + u - de_r = 1 / de_u - fine_r = de_r + roof1[:f] - fine_u = 1 / fine_r - expect(de_u).to be_within(TOL).of(0.149) # above required Ut of 0.138 - expect(de_r).to be_within(TOL).of(fine_insulation_r) # 6.695, not 11.65 - ratio = -(uprated_layer_r - de_r) * 100 / (uprated_layer_r + roof1[:f]) - expect(ratio).to be_within(TOL).of(-42.03) - expect(surfaces[fine]).to have_key(:ratio) - expect(surfaces[fine][:ratio]).to be_within(TOL).of(ratio) - - ua = bulk_u * surfaces[bulk][:net] + fine_u * surfaces[fine][:net] - ave_u = ua / area - expect(ave_u).to be_within(TOL).of(argh[:roof_ut]) # area-weighted average + layer = layer.to_MasslessOpaqueMaterial.get + expect(layer.thermalResistance.round(2)).to eq(11.16) # m2.K/W (R63) + end file = File.join(__dir__, "files/osms/out/up_warehouse.osm") model.save(file, true) @@ -9449,91 +9359,26 @@ expect(model).to_not be_empty model = model.get - # Mimics measure. - walls = {c: {}, dft: "ALL wall constructions" } - roofs = {c: {}, dft: "ALL roof constructions" } - flors = {c: {}, dft: "ALL floor constructions"} - - walls[:c][walls[:dft]] = {a: 100000000000000} - roofs[:c][roofs[:dft]] = {a: 100000000000000} - flors[:c][flors[:dft]] = {a: 100000000000000} - - walls[:chx] = OpenStudio::StringVector.new - roofs[:chx] = OpenStudio::StringVector.new - flors[:chx] = OpenStudio::StringVector.new + w1 = "Typical Insulated Metal Building Wall R-8.85 1" + w2 = "Typical Insulated Metal Building Wall R-11.9" + w3 = "Typical Insulated Metal Building Wall R-11.9 1" model.getSurfaces.each do |s| - type = s.surfaceType.downcase - next unless ["wall", "roofceiling", "floor"].include?(type) + next unless s.surfaceType.downcase == "wall" next unless s.outsideBoundaryCondition.downcase == "outdoors" next if s.construction.empty? next if s.construction.get.to_LayeredConstruction.empty? - lc = s.construction.get.to_LayeredConstruction.get - id = lc.nameString - next if walls[:c].key?(id) - next if roofs[:c].key?(id) - next if flors[:c].key?(id) - - a = lc.getNetArea - f = s.filmResistance - - case type - when "wall" - walls[:c][id] = {a: a, lc: lc} - walls[:c][id][:f] = f unless walls[:c][id].key?(:f) - walls[:c][id][:f] = f if walls[:c][id][:f] > f - when "roofceiling" - roofs[:c][id] = {a: a, lc: lc} - roofs[:c][id][:f] = f unless roofs[:c][id].key?(:f) - roofs[:c][id][:f] = f if roofs[:c][id][:f] > f - else - flors[:c][id] = {a: a, lc: lc} - flors[:c][id][:f] = f unless flors[:c][id].key?(:f) - flors[:c][id][:f] = f if flors[:c][id][:f] > f - end + lc = s.construction.get.to_LayeredConstruction.get + id = lc.nameString + flm = s.filmResistance + expect([w1, w2, w3]).to include(id) + expect(flm.round(4)).to eq(0.1496) + expect(TBD.rsi(lc, flm).round(3)).to eq(1.558) if id == w1 # R08.8 + expect(TBD.rsi(lc, flm).round(3)).to eq(2.096) if id == w2 # R11.9 + expect(TBD.rsi(lc, flm).round(3)).to eq(2.096) if id == w3 # R11.9 end - walls[:c] = walls[:c].sort_by{ |k,v| v[:a] }.reverse!.to_h - roofs[:c] = roofs[:c].sort_by{ |k,v| v[:a] }.reverse!.to_h - flors[:c] = flors[:c].sort_by{ |k,v| v[:a] }.reverse!.to_h - - walls[:c][walls[:dft]][:a] = 0 - roofs[:c][roofs[:dft]][:a] = 0 - flors[:c][flors[:dft]][:a] = 0 - - walls[:c].keys.each { |id| walls[:chx] << id } - roofs[:c].keys.each { |id| roofs[:chx] << id } - flors[:c].keys.each { |id| flors[:chx] << id } - - expect(walls[:c].size).to eq(4) - - w1 = "Typical Insulated Metal Building Wall R-8.85 1" - w2 = "Typical Insulated Metal Building Wall R-11.9" - w3 = "Typical Insulated Metal Building Wall R-11.9 1" - - expect(walls[:c]).to have_key(w1) - expect(walls[:c]).to have_key(w2) - expect(walls[:c]).to have_key(w3) - - expect(walls[:c].keys[0]).to eq("ALL wall constructions") - expect(walls[:c]["ALL wall constructions"][:a]).to be_within(TOL).of(0) - - wall1 = walls[:c][w1] - wall2 = walls[:c][w2] - wall3 = walls[:c][w3] - - expect(wall1[:a] > wall2[:a]).to be true - expect(wall2[:a] > wall3[:a]).to be true - - expect(wall1[:f]).to be_within(TOL).of(wall2[:f]) - expect(wall3[:f]).to be_within(TOL).of(wall3[:f]) - expect(wall1[:f]).to be_within(TOL).of(0.150) - expect(wall2[:f]).to be_within(TOL).of(0.150) - expect(wall3[:f]).to be_within(TOL).of(0.150) - expect(1/TBD.rsi(wall1[:lc], wall1[:f])).to be_within(TOL).of(0.642) # R08.8 - expect(1/TBD.rsi(wall2[:lc], wall2[:f])).to be_within(TOL).of(0.477) # R11.9 - # Deeper dive into w1 (more prevalent). targeted = model.getConstructionByName(w1) expect(targeted).to_not be_empty @@ -9552,10 +9397,9 @@ # Set w1 (a wall construction) as the 'Bulk Storage Roof' construction. This # triggers a TBD warning when uprating: a safeguard limiting uprated - # constructions to single surface type (e.g. can't be referenced by both + # constructions to single surface types (e.g. can't be referenced by both # roof AND wall surfaces). - bulk = "Bulk Storage Roof" - + bulk = "Bulk Storage Roof" bulk_roof = model.getSurfaceByName(bulk) expect(bulk_roof).to_not be_empty bulk_roof = bulk_roof.get @@ -9574,7 +9418,7 @@ argh[:wall_option ] = "ALL wall constructions" argh[:option ] = "poor (BETBG)" argh[:uprate_walls] = true - argh[:wall_ut ] = 0.210 # (R27) + argh[:wall_ut ] = 0.210 # (R27), NECB 2017 json = TBD.process(model, argh) expect(json).to be_a(Hash) @@ -9582,17 +9426,24 @@ expect(json).to have_key(:surfaces) io = json[:io ] surfaces = json[:surfaces] + + # PSI-factors of the "poor (BETBG)" set are too conductive. The total heat + # loss (W/K) from thermal bridging is too great for insulation materials to + # absorb (given TBD/OSut admissible ranges). TBD fails to completely uprate + # walls to meet NECB 2017 Ut requirements. In such cases, TBD logs the + # failure, yet partially uprates non-compliant wall constructions by setting + # the uprated Uo to UMIN. expect(TBD.warn?).to be true - expect(TBD.logs.size).to eq(1) + expect(TBD.logs.size).to eq(3) + expect(TBD.logs[0][:message]).to include("Cloning 'Bulk Storage Roof' ") + expect(TBD.logs[1][:message]).to include("Negative ") + expect(TBD.logs[2][:message]).to include("Unable to completely uprate ") expect(surfaces).to be_a(Hash) expect(surfaces.size).to eq(23) expect(io).to be_a(Hash) expect(io).to have_key(:edges) expect(io[:edges].size).to eq(300) - msg = "Cloning '#{bulk}' construction - not '#{w1}' (TBD::uprate)" - expect(TBD.logs.first[:message]).to eq(msg) - bulk_roof = model.getSurfaceByName(bulk) expect(bulk_roof).to_not be_empty bulk_roof = bulk_roof.get @@ -9608,34 +9459,30 @@ layer0 = bulk_construction.layers[0] layer1 = bulk_construction.layers[1] layer2 = bulk_construction.layers[2] - expect(layer1.nameString).to eq("#{bulk} m tbd")# not uprated - - layer = layer0.to_StandardOpaqueMaterial - expect(layer).to_not be_empty - siding = layer.get.thickness / layer.get.thermalConductivity - layer = layer2.to_StandardOpaqueMaterial - expect(layer).to_not be_empty - gypsum = layer.get.thickness / layer.get.thermalConductivity - extra = siding + gypsum + wall1[:f] + expect(layer1.nameString).to eq("#{bulk} m tbd") # not uprated - wall_surfaces = [] + uA = 0 + m2 = 0 model.getSurfaces.each do |s| next unless s.surfaceType.downcase == "wall" next unless s.outsideBoundaryCondition.downcase == "outdoors" - next if s.construction.empty? - next if s.construction.get.to_LayeredConstruction.empty? + expect(s.construction).to_not be_empty + expect(s.construction.get.to_LayeredConstruction).to_not be_empty c = s.construction.get.to_LayeredConstruction.get expect(c.numLayers).to eq(3) expect(c.layers[0]).to eq(layer0) # same as Bulk Storage Roof expect(c.layers[1].nameString).to include(" uprated ") expect(c.layers[1].nameString).to include(" m tbd") expect(c.layers[2]).to eq(layer2) # same as Bulk Storage Roof - wall_surfaces << s + + m2 += s.netArea + uA += s.netArea / TBD.rsi(c, s.filmResistance) end - expect(wall_surfaces.size).to eq(10) + ut = uA / m2 + expect(ut.round(3)).to eq(0.226) # R21, below the required R27 # TBD objects. expect(surfaces).to have_key(bulk) @@ -9651,139 +9498,6 @@ nom = surfaces[bulk][:construction].nameString expect(nom).to include("cloned") - uprated = model.getConstructionByName(w1) # uprated, not yet derated - expect(uprated).to_not be_empty - uprated = uprated.get - expect(uprated.to_LayeredConstruction).to_not be_empty - uprated = uprated.to_LayeredConstruction.get - expect(uprated.layers.size).to eq(3) - uprated_layer_r = 0 - - uprated.layers.each do |layer| - next unless layer.nameString.include?("uprated") - - expect(layer.to_MasslessOpaqueMaterial).to_not be_empty - uprated_layer_r = layer.to_MasslessOpaqueMaterial.get.thermalResistance - expect(uprated_layer_r).to be_within(TOL).of(51.92) # m2.K/W - end - - rt = TBD.rsi(uprated, wall1[:f]) - expect(1/rt).to be_within(TOL).of(0.019) # 52.63 (with surface films) - - # Loop through all walls, fetch nets areas & heatlosses from psi's. - net = 0 - hloss = 0 - - surfaces.each do |id, surface| - next unless surface.key?(:boundary) - next unless surface[:boundary] == "outdoors" - next unless surface.key?(:type) - next unless surface[:type] == :wall - next unless surface.key?(:construction) - next unless surface.key?(:heatloss) - next unless surface.key?(:net) - - hloss += surface[:heatloss] - net += surface[:net ] - end - - expect(hloss).to be_within(TOL).of(485.59) - expect(net ).to be_within(TOL).of(2411.7) - u = hloss / net - de_u = 1 / uprated_layer_r + u - de_r = 1 / de_u - new_r = de_r + extra - new_u = 1 / new_r - expect(new_r).to be_within(TOL).of(4.76) # R27 (NECB2017) - expect(new_u).to be_within(TOL).of(argh[:wall_ut]) # 0.210 W/m2.K - - # Bulk storage wall demonstration. - wll1 = "Bulk Storage Left Wall" - wll2 = "Bulk Storage Rear Wall" - wll3 = "Bulk Storage Right Wall" - rs = {} - - [wll1, wll2, wll3].each do |i| - sface = model.getSurfaceByName(i) - expect(sface).to_not be_empty - sface = sface.get - - c = sface.construction - expect(c).to_not be_empty - c = c.get.to_LayeredConstruction - expect(c).to_not be_empty - c = c.get - - expect(c.numLayers).to eq(3) - layer = c.layers[0].to_StandardOpaqueMaterial - expect(layer).to_not be_empty - - d = layer.get.thickness - k = layer.get.thermalConductivity - expect(d / k).to be_within(TOL).of(siding) - - layer = c.layers[1].to_MasslessOpaqueMaterial - expect(layer).to_not be_empty - rsi = layer.get.thermalResistance - - expect(rsi).to be_within(TOL).of(4.1493) if i == wll1 - expect(rsi).to be_within(TOL).of(5.4252) if i == wll2 - expect(rsi).to be_within(TOL).of(5.3642) if i == wll3 - - layer = c.layers[2].to_StandardOpaqueMaterial - expect(layer).to_not be_empty - d = layer.get.thickness - k = layer.get.thermalConductivity - expect(d / k).to be_within(TOL).of(gypsum) - - u = c.thermalConductance - expect(u).to_not be_empty - rs[i] = 1 / u.get - end - - expect(rs).to have_key(wll1) - expect(rs).to have_key(wll2) - expect(rs).to have_key(wll3) - expect(rs[wll1]).to be_within(TOL).of(4.2287) - expect(rs[wll2]).to be_within(TOL).of(5.5046) - expect(rs[wll3]).to be_within(TOL).of(5.4436) - - u = surfaces[wll1][:heatloss] / surfaces[wll1][:net] - expect(u).to be_within(TOL).of(0.2217) # W/m2.K from thermal bridging - de_u = 1 / uprated_layer_r + u - de_r = 1 / de_u - new_r = de_r + extra - new_u = 1 / new_r - expect(new_r).to be_within(TOL).of(4.3782) # R24.9 ... lot of doors - ratio = -(uprated_layer_r - de_r) * 100 / rt - expect(ratio).to be_within(TOL).of(-91.60) - expect(surfaces[wll1]).to have_key(:ratio) - expect(surfaces[wll1][:ratio]).to be_within(TOL).of(ratio) - - u = surfaces[wll2][:heatloss] / surfaces[wll2][:net] - expect(u).to be_within(TOL).of(0.1652) # W/m2.K from thermal bridging - de_u = 1 / uprated_layer_r + u - de_r = 1 / de_u - new_r = de_r + extra - new_u = 1 / new_r - expect(new_r).to be_within(TOL).of(5.6542) # R32.1 ... no openings - ratio = -(uprated_layer_r - de_r) * 100 / rt - expect(ratio).to be_within(TOL).of(-89.16) - expect(surfaces[wll2]).to have_key(:ratio) - expect(surfaces[wll2][:ratio]).to be_within(TOL).of(ratio) - - u = surfaces[wll3][:heatloss] / surfaces[wll3][:net] - expect(u).to be_within(TOL).of(0.1671) # W/m2.K from thermal bridging - de_u = 1 / uprated_layer_r + u - de_r = 1 / de_u - new_r = de_r + extra - new_u = 1 / new_r - expect(new_r).to be_within(TOL).of(5.5931) # R31.8 ... a few doors - ratio = -(uprated_layer_r - de_r) * 100 / rt - expect(ratio).to be_within(TOL).of(-89.27) - expect(surfaces[wll3]).to have_key(:ratio) - expect(surfaces[wll3][:ratio]).to be_within(TOL).of(ratio) - file = File.join(__dir__, "files/osms/out/up2_warehouse.osm") model.save(file, true) end @@ -9798,105 +9512,21 @@ expect(model).to_not be_empty model = model.get - # Mimics measure. - walls = { c: {}, dft: "ALL wall constructions" } - roofs = { c: {}, dft: "ALL roof constructions" } - flors = { c: {}, dft: "ALL floor constructions"} - - walls[:c][walls[:dft]] = {a: 100000000000000} - roofs[:c][roofs[:dft]] = {a: 100000000000000} - flors[:c][flors[:dft]] = {a: 100000000000000} - - walls[:chx] = OpenStudio::StringVector.new - roofs[:chx] = OpenStudio::StringVector.new - flors[:chx] = OpenStudio::StringVector.new - - model.getSurfaces.each do |s| - type = s.surfaceType.downcase - next unless ["wall", "roofceiling", "floor"].include?(type) - next unless s.outsideBoundaryCondition.downcase == "outdoors" - next if s.construction.empty? - next if s.construction.get.to_LayeredConstruction.empty? - - lc = s.construction.get.to_LayeredConstruction.get - id = lc.nameString - next if walls[:c].key?(id) - next if roofs[:c].key?(id) - next if flors[:c].key?(id) - - a = lc.getNetArea - f = s.filmResistance - - case type - when "wall" - walls[:c][id] = {a: a, lc: lc} - walls[:c][id][:f] = f unless walls[:c][id].key?(:f) - walls[:c][id][:f] = f if walls[:c][id][:f] > f - when "roofceiling" - roofs[:c][id] = {a: a, lc: lc} - roofs[:c][id][:f] = f unless roofs[:c][id].key?(:f) - roofs[:c][id][:f] = f if roofs[:c][id][:f] > f - else - flors[:c][id] = {a: a, lc: lc} - flors[:c][id][:f] = f unless flors[:c][id].key?(:f) - flors[:c][id][:f] = f if flors[:c][id][:f] > f - end - end - - walls[:c] = walls[:c].sort_by{ |k,v| v[:a] }.reverse!.to_h - roofs[:c] = roofs[:c].sort_by{ |k,v| v[:a] }.reverse!.to_h - flors[:c] = flors[:c].sort_by{ |k,v| v[:a] }.reverse!.to_h - - walls[:c][walls[:dft]][:a] = 0 - roofs[:c][roofs[:dft]][:a] = 0 - flors[:c][flors[:dft]][:a] = 0 - - walls[:c].keys.each { |id| walls[:chx] << id } - roofs[:c].keys.each { |id| roofs[:chx] << id } - flors[:c].keys.each { |id| flors[:chx] << id } - - expect(walls[:c].size).to eq(4) - w1 = "Typical Insulated Metal Building Wall R-8.85 1" w2 = "Typical Insulated Metal Building Wall R-11.9" w3 = "Typical Insulated Metal Building Wall R-11.9 1" - expect(walls[:c]).to have_key(w1) - expect(walls[:c]).to have_key(w2) - expect(walls[:c]).to have_key(w3) - - expect(walls[:c].keys[0]).to eq("ALL wall constructions") - expect(walls[:c]["ALL wall constructions"][:a]).to be_within(TOL).of(0) - - wall1 = walls[:c][w1] - wall2 = walls[:c][w2] - wall3 = walls[:c][w3] - - expect(wall1[:a] > wall2[:a]).to be true - expect(wall2[:a] > wall3[:a]).to be true - - expect(wall1[:f]).to be_within(TOL).of(wall2[:f]) - expect(wall3[:f]).to be_within(TOL).of(wall3[:f]) - expect(wall1[:f]).to be_within(TOL).of(0.150) - expect(wall2[:f]).to be_within(TOL).of(0.150) - expect(wall3[:f]).to be_within(TOL).of(0.150) - - expect(1/TBD.rsi(wall1[:lc], wall1[:f])).to be_within(TOL).of(0.642) # R08.8 - expect(1/TBD.rsi(wall2[:lc], wall2[:f])).to be_within(TOL).of(0.477) # R11.9 - # Deeper dive into w1 (more prevalent). targeted = model.getConstructionByName(w1) expect(targeted).to_not be_empty targeted = targeted.get expect(targeted.to_LayeredConstruction).to_not be_empty targeted = targeted.to_LayeredConstruction.get - expect(targeted.is_a?(OpenStudio::Model::LayeredConstruction)).to be true expect(targeted.layers.size).to eq(3) targeted.layers.each do |layer| next unless layer.nameString == "Typical Insulation R-7.55 1" - expect(layer.to_MasslessOpaqueMaterial).to_not be_empty layer = layer.to_MasslessOpaqueMaterial.get expect(layer.thermalResistance).to be_within(TOL).of(1.33) # m2.K/W (R7.6) @@ -9933,6 +9563,7 @@ expect(json).to have_key(:surfaces) io = json[:io ] surfaces = json[:surfaces] + expect(TBD.warn?).to be true expect(TBD.logs.size).to eq(1) expect(surfaces).to be_a(Hash) @@ -9961,187 +9592,39 @@ layer2 = bulk_construction.layers[2] expect(layer1.nameString).to eq("#{bulk} m tbd") # not uprated - layer = layer0.to_StandardOpaqueMaterial - expect(layer).to_not be_empty - siding = layer.get.thickness / layer.get.thermalConductivity - layer = layer2.to_StandardOpaqueMaterial - expect(layer).to_not be_empty - gypsum = layer.get.thickness / layer.get.thermalConductivity - extra = siding + gypsum + wall1[:f] - - wall_surfaces = [] + uA = 0 + m2 = 0 model.getSurfaces.each do |s| next unless s.surfaceType.downcase == "wall" next unless s.outsideBoundaryCondition.downcase == "outdoors" - next if s.construction.empty? - next if s.construction.get.to_LayeredConstruction.empty? + expect(s.construction).to_not be_empty + expect(s.construction.get.to_LayeredConstruction).to_not be_empty c = s.construction.get.to_LayeredConstruction.get expect(c.numLayers).to eq(3) expect(c.layers[0]).to eq(layer0) # same as Bulk Storage Roof expect(c.layers[1].nameString).to include(" uprated ") expect(c.layers[1].nameString).to include(" m tbd") expect(c.layers[2]).to eq(layer2) # same as Bul;k Storage Roof - wall_surfaces << s + + m2 += s.netArea + uA += s.netArea / TBD.rsi(c, s.filmResistance) end - expect(wall_surfaces.size).to eq(10) + ut = uA / m2 + expect(ut.round(3)).to eq(0.210) # R27, per NECB 2017 requirements # TBD objects. expect(surfaces).to have_key(bulk) - expect(surfaces[bulk]).to have_key(:construction) # not yet derated expect(surfaces[bulk]).to have_key(:net) expect(surfaces[bulk]).to have_key(:heatloss) expect(surfaces[bulk][:heatloss]).to be_within(TOL).of( 49.80) expect(surfaces[bulk][:net ]).to be_within(TOL).of(3157.28) + expect(surfaces[bulk]).to have_key(:construction) # not yet derated nom = surfaces[bulk][:construction].nameString expect(nom).to include("cloned") - uprated = model.getConstructionByName(w1) # uprated, not yet derated - expect(uprated).to_not be_empty - uprated = uprated.get - expect(uprated.to_LayeredConstruction).to_not be_empty - uprated = uprated.to_LayeredConstruction.get - expect(uprated.layers.size).to eq(3) - - uprated_layer_r = 0 - - uprated.layers.each do |layer| - next unless layer.nameString.include?("uprated") - - expect(layer.to_MasslessOpaqueMaterial).to_not be_empty - layer = layer.to_MasslessOpaqueMaterial.get - - # The switch from "poor" to "efficient" thermal bridging details is key. - uprated_layer_r = layer.thermalResistance - expect(uprated_layer_r).to be_within(TOL).of(5.932) # vs 51.92 m2.K/W !! - end - - rt = TBD.rsi(uprated, wall1[:f]) - expect(1/rt).to be_within(TOL).of(0.162) # 6.16 (with surface films), or R35 - # Still, that R35 factors-in "minor" or "clear-field" thermal bridging - # from studs, Z-bars and/or fasteners. The final, nominal insulation layer - # may need to be ~R40. That's 8" of XPS in a wall. - - # Loop through all walls, fetch nets areas & heatlosses from psi's. - net = 0 - hloss = 0 - - surfaces.each do |id, surface| - next unless surface.key?(:boundary) - next unless surface.key?(:construction) - next unless surface.key?(:heatloss) - next unless surface.key?(:net) - next unless surface.key?(:type) - next unless surface[:boundary] == "outdoors" - next unless surface[:type ] == :wall - - hloss += surface[:heatloss] - net += surface[:net] - end - - expect(hloss).to be_within(TOL).of( 125.48) # vs 485.59 W/K - expect(net ).to be_within(TOL).of(2411.70) - u = hloss / net - de_u = 1 / uprated_layer_r + u - de_r = 1 / de_u - new_r = de_r + extra - new_u = 1 / new_r - expect(new_r).to be_within(TOL).of( 4.76) # R27 (NECB2017) - expect(new_u).to be_within(TOL).of(argh[:wall_ut]) # 0.210 W/m2.K - - # Bulk storage wall demonstration. - wll1 = "Bulk Storage Left Wall" - wll2 = "Bulk Storage Rear Wall" - wll3 = "Bulk Storage Right Wall" - rs = {} - - [wll1, wll2, wll3].each do |i| - sface = model.getSurfaceByName(i) - expect(sface).to_not be_empty - sface = sface.get - - c = sface.construction - expect(c).to_not be_empty - c = c.get.to_LayeredConstruction - expect(c).to_not be_empty - c = c.get - expect(c.numLayers).to eq(3) - - layer = c.layers[0].to_StandardOpaqueMaterial - expect(layer).to_not be_empty - - d = layer.get.thickness - k = layer.get.thermalConductivity - expect(d / k).to be_within(TOL).of(siding) - - layer = c.layers[1].to_MasslessOpaqueMaterial - expect(layer).to_not be_empty - - rsi = layer.get.thermalResistance - expect(rsi).to be_within(TOL).of(4.3381) if i == wll1 # vs 4.1493 m2.K/W - expect(rsi).to be_within(TOL).of(4.8052) if i == wll2 # vs 5.4252 m2.K/W - expect(rsi).to be_within(TOL).of(4.7446) if i == wll3 # vs 5.3642 m2.K/W - - layer = c.layers[2].to_StandardOpaqueMaterial - expect(layer).to_not be_empty - d = layer.get.thickness - k = layer.get.thermalConductivity - expect(d / k).to be_within(TOL).of(gypsum) - - u = c.thermalConductance - expect(u).to_not be_empty - rs[i] = 1 / u.get - end - - expect(rs).to have_key(wll1) - expect(rs).to have_key(wll2) - expect(rs).to have_key(wll3) - - expect(rs[wll1]).to be_within(TOL).of(4.4175) # vs 4.2287 m2.K/W - expect(rs[wll2]).to be_within(TOL).of(4.8847) # vs 5.5046 m2.K/W - expect(rs[wll3]).to be_within(TOL).of(4.8240) # vs 5.4436 m2.K/W - - u = surfaces[wll1][:heatloss] / surfaces[wll1][:net] - expect(u).to be_within(TOL).of(0.0619) # vs 0.2217 W/m2.K from bridging - - de_u = 1 / uprated_layer_r + u - de_r = 1 / de_u - new_r = de_r + extra - new_u = 1 / new_r - expect(new_r).to be_within(TOL).of(4.5671) # R26, vs R24.9 - ratio = -(uprated_layer_r - de_r) * 100 / rt - expect(ratio).to be_within(TOL).of(-25.87) # vs -91.60 % - expect(surfaces[wll1]).to have_key(:ratio) - expect(surfaces[wll1][:ratio]).to be_within(TOL).of(ratio) - - u = surfaces[wll2][:heatloss] / surfaces[wll2][:net] - expect(u).to be_within(TOL).of(0.0395) # vs 0.1652 W/m2.K from bridging - - de_u = 1 / uprated_layer_r + u - de_r = 1 / de_u - new_r = de_r + extra - new_u = 1 / new_r - expect(new_r).to be_within(TOL).of(5.0342)# R28.6, vs R32.1 - ratio = -(uprated_layer_r - de_r) * 100 / rt - expect(ratio).to be_within(TOL).of(-18.29) # vs -89.16% - expect(surfaces[wll2]).to have_key(:ratio) - expect(surfaces[wll2][:ratio]).to be_within(TOL).of(ratio) - - u = surfaces[wll3][:heatloss] / surfaces[wll3][:net] - expect(u).to be_within(TOL).of(0.0422)# vs 0.1671 W/m2.K from bridging - - de_u = 1 / uprated_layer_r + u - de_r = 1 / de_u - new_r = de_r + extra - new_u = 1 / new_r - expect(new_r).to be_within(TOL).of(4.9735) # R28.2, vs R31.8 - ratio = -(uprated_layer_r - de_r) * 100 / rt - expect(ratio).to be_within(TOL).of(-19.27) # vs -89.27% - expect(surfaces[wll3]).to have_key(:ratio) - expect(surfaces[wll3][:ratio]).to be_within(TOL).of(ratio) - file = File.join(__dir__, "files/osms/out/up3_warehouse.osm") model.save(file, true) end @@ -10163,7 +9646,7 @@ model.getSurfaces.each do |s| next unless s.surfaceType == "Wall" - next unless s.outsideBoundaryCondition.downcase == "outdoors" + next unless s.outsideBoundaryCondition == "Outdoors" walls << s.nameString c = s.construction @@ -10183,14 +9666,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) @@ -10304,14 +9786,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). @@ -10332,52 +9819,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.downcase == "outdoors" + 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 # -- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- -- # @@ -10436,7 +9941,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 @@ -10449,14 +9961,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(TBD.status).to be_zero - expect(argh).to have_key(:wall_uo) - expect(argh[:wall_uo]).to be_within(0.0001).of(UMIN) # RSi 100 (R568) + expect(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 @@ -10464,7 +9977,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) @@ -10477,11 +9990,16 @@ 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(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 @@ -11887,8 +11405,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) @@ -11931,8 +11448,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) @@ -11993,7 +11509,6 @@ argh = { option: "code (Quebec)" } json = TBD.process(model, argh) - puts TBD.logs expect(TBD.status).to be_zero expect(json).to be_a(Hash) expect(json).to have_key(:io) @@ -12019,7 +11534,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)) @@ -12032,7 +11546,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) @@ -12046,6 +11567,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 = [] @@ -12073,11 +11596,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) @@ -12135,8 +11658,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) @@ -12281,9 +11803,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) @@ -12378,8 +11899,13 @@ c = c.get.to_LayeredConstruction expect(c).to_not be_empty c = c.get - f = TBD.filmResistances(:ceiling, s.tilt) - expect(TBD.rsi(c, f)).to be_within(TOL).of(6.44) + + 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) @@ -12471,8 +11997,9 @@ surfaces.each do |nom, surface| expect(surface).to be_a(Hash) - expect(surface).to have_key(:filmRSI) + 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) @@ -12597,8 +12124,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) @@ -12702,7 +12228,7 @@ model.getSurfaces.each do |s| next unless s.surfaceType == "RoofCeiling" - next unless s.outsideBoundaryCondition.downcase == "outdoors" + next unless s.outsideBoundaryCondition == "Outdoors" roofs << s.nameString c = s.construction @@ -12769,8 +12295,8 @@ surfaces.each do |nom, surface| expect(surface).to be_a(Hash) - expect(surface).to have_key(:filmRSI) 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) @@ -14922,11 +14448,30 @@ argh[:wall_ut ] = 0.210 # NECB CZ7 2017 (RSi 4.76 / R27) TBD.process(model, argh) - expect(TBD.status).to be_zero - expect(TBD.logs).to be_empty - + 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[:wall_uo]).to be_within(TOL).of(0.00236) # RSi 423 (R2K) + + uA = 0 + + model.getSurfaces.each do |surface| + next unless surface.outsideBoundaryCondition.downcase == "outdoors" + next unless surface.surfaceType.downcase == "wall" + + expect(surface.construction).to_not be_empty + expect(surface.construction.get.to_LayeredConstruction).to_not be_empty + + lc = surface.construction.get.to_LayeredConstruction.get + uo = 1 / TBD.rsi(lc, surface.filmResistance) + uA += surface.netArea * uo + end + + uo = uA / net + expect(uo.round(3)).to eq(0.216) # R21, below NECB 2017 required R27 end end From ca934f82dc3212c7d625a6a461fa11a8be85b0d8 Mon Sep 17 00:00:00 2001 From: brgix Date: Fri, 10 Apr 2026 16:26:17 -0400 Subject: [PATCH 04/10] Syncs unit tests with TBD repo (minor edits) --- spec/tbd_tests_spec.rb | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/spec/tbd_tests_spec.rb b/spec/tbd_tests_spec.rb index 1cb9994..885b65f 100644 --- a/spec/tbd_tests_spec.rb +++ b/spec/tbd_tests_spec.rb @@ -1876,8 +1876,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) @@ -2110,8 +2109,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) @@ -2228,8 +2226,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) @@ -2316,8 +2313,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) @@ -2389,8 +2385,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) @@ -5149,7 +5144,7 @@ it "can process JSON surface KHI entries" do translator = OpenStudio::OSVersion::VersionTranslator.new - expect(TBD.level).to eq(DBG) + expect(TBD.level ).to eq(DBG) expect(TBD.clean!).to eq(DBG) # First, basic IO tests with invalid entries. From 7338bedca4de7c46671b49d179f5d59e1c951f79 Mon Sep 17 00:00:00 2001 From: brgix Date: Sat, 11 Apr 2026 08:28:01 -0400 Subject: [PATCH 05/10] Purges LoScrigno generation tests --- spec/files/osms/in/loscrigno.osm | 979 ++++++++++++++++ spec/tbd_tests_spec.rb | 1852 +----------------------------- 2 files changed, 984 insertions(+), 1847 deletions(-) create mode 100644 spec/files/osms/in/loscrigno.osm diff --git a/spec/files/osms/in/loscrigno.osm b/spec/files/osms/in/loscrigno.osm new file mode 100644 index 0000000..0473dd8 --- /dev/null +++ b/spec/files/osms/in/loscrigno.osm @@ -0,0 +1,979 @@ + +OS:Version, + {4df21e7e-780c-4bcc-bf77-42dae775cf94}, !- Handle + 2.9.1; !- Version Identifier + +OS:Building, + {87b28c47-3f08-4d45-becb-314a22ebfc50}, !- Handle + Building 1, !- Name + , !- Building Sector Type + , !- North Axis {deg} + , !- Nominal Floor to Floor Height {m} + , !- Space Type Name + {5db98e33-ec1f-42ec-a83e-18c4e850988c}, !- Default Construction Set Name + ; !- Default Schedule Set Name + +OS:ShadingSurfaceGroup, + {b544d745-4934-4c2b-9156-a147eaa98b85}, !- Handle + Shading Surface Group 1, !- Name + Building; !- Shading Surface Type + +OS:Space, + {63da0e2b-6e37-4b5e-adda-47a3a9f3b6bd}, !- Handle + scrigno_gallery, !- Name + , !- Space Type Name + , !- Default Construction Set Name + , !- Default Schedule Set Name + , !- Direction of Relative North {deg} + , !- X Origin {m} + , !- Y Origin {m} + , !- Z Origin {m} + , !- Building Story Name + {63115b0c-6adf-4949-9f4d-d50da862eeb0}; !- Thermal Zone Name + +OS:Space, + {9c8278ca-49e4-41ac-b043-c8836480df0b}, !- Handle + scrigno_plenum, !- Name + , !- Space Type Name + , !- Default Construction Set Name + , !- Default Schedule Set Name + , !- Direction of Relative North {deg} + , !- X Origin {m} + , !- Y Origin {m} + , !- Z Origin {m} + , !- Building Story Name + {b4818e3a-f20f-41fe-8df2-235cccc8fece}; !- Thermal Zone Name + +OS:Construction, + {505638a4-9c66-40e0-bf1e-a6af98605768}, !- Handle + scrigno_construction, !- Name + , !- Surface Rendering Name + {88aaf78e-31f7-4a5c-a208-3bd3fd5faf3f}, !- Layer 1 + {ec5be9a7-3992-465b-a865-18eb5aa93dbf}, !- Layer 2 + {70abb29c-2a7c-4d45-b31e-acec66bd4b2e}; !- Layer 3 + +OS:Construction, + {b6ad066a-6592-406b-ad31-ddbec3b83469}, !- Handle + scrigno_fen, !- Name + , !- Surface Rendering Name + {d3f0ad72-031b-4eb9-b6ca-4209e6a7df1e}; !- Layer 1 + +OS:Construction, + {8509976d-6b3f-44db-b0a6-f9903e9a2412}, !- Handle + elevator, !- Name + , !- Surface Rendering Name + {88aaf78e-31f7-4a5c-a208-3bd3fd5faf3f}, !- Layer 1 + {595a94e6-ea35-4c07-90c4-a12b565cbcab}, !- Layer 2 + {70abb29c-2a7c-4d45-b31e-acec66bd4b2e}; !- Layer 3 + +OS:Construction, + {353f4123-4ddd-428e-83e8-c496a5d55acc}, !- Handle + scrigno_shading, !- Name + , !- Surface Rendering Name + {88aaf78e-31f7-4a5c-a208-3bd3fd5faf3f}; !- Layer 1 + +OS:WindowMaterial:SimpleGlazingSystem, + {d3f0ad72-031b-4eb9-b6ca-4209e6a7df1e}, !- Handle + scrigno_glazing, !- Name + 2, !- U-Factor {W/m2-K} + 0.5, !- Solar Heat Gain Coefficient + 0.7; !- Visible Transmittance + +OS:Material:NoMass, + {88aaf78e-31f7-4a5c-a208-3bd3fd5faf3f}, !- Handle + scrigno_exterior, !- Name + Rough, !- Roughness + 0.3626, !- Thermal Resistance {m2-K/W} + 0.9, !- Thermal Absorptance + 0.7, !- Solar Absorptance + 0.7; !- Visible Absorptance + +OS:Material:NoMass, + {595a94e6-ea35-4c07-90c4-a12b565cbcab}, !- Handle + xps8x25mm, !- Name + Rough, !- Roughness + 7.04, !- Thermal Resistance {m2-K/W} + 0.9, !- Thermal Absorptance + 0.7, !- Solar Absorptance + 0.7; !- Visible Absorptance + +OS:Material, + {ec5be9a7-3992-465b-a865-18eb5aa93dbf}, !- Handle + scrigno_insulation, !- Name + MediumRough, !- Roughness + 0.1184, !- Thickness {m} + 0.045, !- Conductivity {W/m-K} + 265, !- Density {kg/m3} + 836.8, !- Specific Heat {J/kg-K} + 0.9, !- Thermal Absorptance + 0.7, !- Solar Absorptance + 0.7; !- Visible Absorptance + +OS:Material, + {70abb29c-2a7c-4d45-b31e-acec66bd4b2e}, !- Handle + scrigno_interior, !- Name + MediumRough, !- Roughness + 0.0126, !- Thickness {m} + 0.16, !- Conductivity {W/m-K} + 784.9, !- Density {kg/m3} + 830, !- Specific Heat {J/kg-K} + 0.9, !- Thermal Absorptance + 0.9, !- Solar Absorptance + 0.9; !- Visible Absorptance + +OS:DefaultSurfaceConstructions, + {8f69d8f6-8f59-4a2a-bb42-b085190f352c}, !- Handle + Default Surface Constructions 1, !- Name + {505638a4-9c66-40e0-bf1e-a6af98605768}, !- Floor Construction Name + {505638a4-9c66-40e0-bf1e-a6af98605768}, !- Wall Construction Name + {505638a4-9c66-40e0-bf1e-a6af98605768}; !- Roof Ceiling Construction Name + +OS:DefaultSubSurfaceConstructions, + {a06435e7-3386-47e8-82c7-e49aeddee9f9}, !- Handle + Default Sub Surface Constructions 1, !- Name + {b6ad066a-6592-406b-ad31-ddbec3b83469}, !- Fixed Window Construction Name + {b6ad066a-6592-406b-ad31-ddbec3b83469}, !- Operable Window Construction Name + {b6ad066a-6592-406b-ad31-ddbec3b83469}, !- Door Construction Name + {b6ad066a-6592-406b-ad31-ddbec3b83469}, !- Glass Door Construction Name + {b6ad066a-6592-406b-ad31-ddbec3b83469}, !- Overhead Door Construction Name + {b6ad066a-6592-406b-ad31-ddbec3b83469}, !- Skylight Construction Name + , !- Tubular Daylight Dome Construction Name + ; !- Tubular Daylight Diffuser Construction Name + +OS:DefaultConstructionSet, + {5db98e33-ec1f-42ec-a83e-18c4e850988c}, !- Handle + Default Construction Set 1, !- Name + {8f69d8f6-8f59-4a2a-bb42-b085190f352c}, !- Default Exterior Surface Constructions Name + {8f69d8f6-8f59-4a2a-bb42-b085190f352c}, !- Default Interior Surface Constructions Name + , !- Default Ground Contact Surface Constructions Name + {a06435e7-3386-47e8-82c7-e49aeddee9f9}, !- Default Exterior SubSurface Constructions Name + {a06435e7-3386-47e8-82c7-e49aeddee9f9}, !- Default Interior SubSurface Constructions Name + {505638a4-9c66-40e0-bf1e-a6af98605768}, !- Interior Partition Construction Name + {353f4123-4ddd-428e-83e8-c496a5d55acc}, !- Space Shading Construction Name + {353f4123-4ddd-428e-83e8-c496a5d55acc}, !- Building Shading Construction Name + {353f4123-4ddd-428e-83e8-c496a5d55acc}, !- Site Shading Construction Name + {505638a4-9c66-40e0-bf1e-a6af98605768}; !- Adiabatic Surface Construction Name + +OS:ShadingSurface, + {798e2899-9e2f-4675-95dd-c18a9fd42eb9}, !- Handle + r1_shade, !- Name + , !- Construction Name + {b544d745-4934-4c2b-9156-a147eaa98b85}, !- Shading Surface Group Name + , !- Transmittance Schedule Name + , !- Number of Vertices + 12.4, 45, 50, !- X,Y,Z Vertex 1 {m} + 12.4, 25, 50, !- X,Y,Z Vertex 2 {m} + 22.7, 25, 50, !- X,Y,Z Vertex 3 {m} + 22.7, 45, 50; !- X,Y,Z Vertex 4 {m} + +OS:ShadingSurface, + {37d9554b-9571-4b64-a7f2-319c798a946b}, !- Handle + r2_shade, !- Name + , !- Construction Name + {b544d745-4934-4c2b-9156-a147eaa98b85}, !- Shading Surface Group Name + , !- Transmittance Schedule Name + , !- Number of Vertices + 22.7, 45, 50, !- X,Y,Z Vertex 1 {m} + 22.7, 37.5, 50, !- X,Y,Z Vertex 2 {m} + 48.7, 37.5, 50, !- X,Y,Z Vertex 3 {m} + 48.7, 45, 50; !- X,Y,Z Vertex 4 {m} + +OS:ShadingSurface, + {083a0167-17cb-4f7c-94bb-7c391e681ee8}, !- Handle + r3_shade, !- Name + , !- Construction Name + {b544d745-4934-4c2b-9156-a147eaa98b85}, !- Shading Surface Group Name + , !- Transmittance Schedule Name + , !- Number of Vertices + 22.7, 32.5, 50, !- X,Y,Z Vertex 1 {m} + 22.7, 25, 50, !- X,Y,Z Vertex 2 {m} + 48.7, 25, 50, !- X,Y,Z Vertex 3 {m} + 48.7, 32.5, 50; !- X,Y,Z Vertex 4 {m} + +OS:ShadingSurface, + {03deed3b-0b14-44dc-a403-4d30ca40d1dc}, !- Handle + r4_shade, !- Name + , !- Construction Name + {b544d745-4934-4c2b-9156-a147eaa98b85}, !- Shading Surface Group Name + , !- Transmittance Schedule Name + , !- Number of Vertices + 48.7, 45, 50, !- X,Y,Z Vertex 1 {m} + 48.7, 25, 50, !- X,Y,Z Vertex 2 {m} + 59, 25, 50, !- X,Y,Z Vertex 3 {m} + 59, 45, 50; !- X,Y,Z Vertex 4 {m} + +OS:ShadingSurface, + {4007719a-0298-4eb4-842c-e339aad63f33}, !- Handle + N_balcony, !- Name + , !- Construction Name + {b544d745-4934-4c2b-9156-a147eaa98b85}, !- Shading Surface Group Name + , !- Transmittance Schedule Name + , !- Number of Vertices + 47.4, 40.2, 44, !- X,Y,Z Vertex 1 {m} + 47.4, 41.7, 44, !- X,Y,Z Vertex 2 {m} + 45.7, 41.7, 44, !- X,Y,Z Vertex 3 {m} + 45.7, 40.2, 44; !- X,Y,Z Vertex 4 {m} + +OS:ShadingSurface, + {1390a962-a5b6-4d25-925b-13af7b4cf5b3}, !- Handle + S_balcony, !- Name + , !- Construction Name + {b544d745-4934-4c2b-9156-a147eaa98b85}, !- Shading Surface Group Name + , !- Transmittance Schedule Name + , !- Number of Vertices + 28.1, 29.8, 44, !- X,Y,Z Vertex 1 {m} + 28.1, 28.3, 44, !- X,Y,Z Vertex 2 {m} + 47.4, 28.3, 44, !- X,Y,Z Vertex 3 {m} + 47.4, 29.8, 44; !- X,Y,Z Vertex 4 {m} + +OS:Surface, + {f6e38115-53c2-430d-b148-53e4f430a27f}, !- Handle + g_W_wall, !- Name + Wall, !- Surface Type + , !- Construction Name + {63da0e2b-6e37-4b5e-adda-47a3a9f3b6bd}, !- Space Name + Outdoors, !- Outside Boundary Condition + , !- Outside Boundary Condition Object + SunExposed, !- Sun Exposure + WindExposed, !- Wind Exposure + , !- View Factor to Ground + , !- Number of Vertices + 17.4, 40.2, 49.5, !- X,Y,Z Vertex 1 {m} + 17.4, 40.2, 44, !- X,Y,Z Vertex 2 {m} + 17.4, 29.8, 44, !- X,Y,Z Vertex 3 {m} + 17.4, 29.8, 49.5; !- X,Y,Z Vertex 4 {m} + +OS:Surface, + {72a8fc75-48cb-42a7-99c2-3ee364eb7850}, !- Handle + g_N_wall, !- Name + Wall, !- Surface Type + , !- Construction Name + {63da0e2b-6e37-4b5e-adda-47a3a9f3b6bd}, !- Space Name + Outdoors, !- Outside Boundary Condition + , !- Outside Boundary Condition Object + SunExposed, !- Sun Exposure + WindExposed, !- Wind Exposure + , !- View Factor to Ground + , !- Number of Vertices + 54, 40.2, 49.5, !- X,Y,Z Vertex 1 {m} + 54, 40.2, 44, !- X,Y,Z Vertex 2 {m} + 17.4, 40.2, 44, !- X,Y,Z Vertex 3 {m} + 17.4, 40.2, 49.5; !- X,Y,Z Vertex 4 {m} + +OS:SubSurface, + {0988119f-6679-4056-a960-739ba0fe7c96}, !- Handle + g_N_door, !- Name + GlassDoor, !- Sub Surface Type + , !- Construction Name + {72a8fc75-48cb-42a7-99c2-3ee364eb7850}, !- Surface Name + , !- Outside Boundary Condition Object + , !- View Factor to Ground + , !- Shading Control Name + , !- Frame and Divider Name + , !- Multiplier + , !- Number of Vertices + 47.4, 40.2, 46, !- X,Y,Z Vertex 1 {m} + 47.4, 40.2, 44, !- X,Y,Z Vertex 2 {m} + 46.4, 40.2, 44, !- X,Y,Z Vertex 3 {m} + 46.4, 40.2, 46; !- X,Y,Z Vertex 4 {m} + +OS:Surface, + {78de7919-12fc-4f5c-9898-dbb303c37021}, !- Handle + g_E_wall, !- Name + Wall, !- Surface Type + , !- Construction Name + {63da0e2b-6e37-4b5e-adda-47a3a9f3b6bd}, !- Space Name + Outdoors, !- Outside Boundary Condition + , !- Outside Boundary Condition Object + SunExposed, !- Sun Exposure + WindExposed, !- Wind Exposure + , !- View Factor to Ground + , !- Number of Vertices + 54, 29.8, 49.5, !- X,Y,Z Vertex 1 {m} + 54, 29.8, 44, !- X,Y,Z Vertex 2 {m} + 54, 40.2, 44, !- X,Y,Z Vertex 3 {m} + 54, 40.2, 49.5; !- X,Y,Z Vertex 4 {m} + +OS:Surface, + {c5276dfd-6277-4572-a4f1-a8bfc6a17059}, !- Handle + g_S1_wall, !- Name + Wall, !- Surface Type + , !- Construction Name + {63da0e2b-6e37-4b5e-adda-47a3a9f3b6bd}, !- Space Name + Outdoors, !- Outside Boundary Condition + , !- Outside Boundary Condition Object + SunExposed, !- Sun Exposure + WindExposed, !- Wind Exposure + , !- View Factor to Ground + , !- Number of Vertices + 17.4, 29.8, 49.5, !- X,Y,Z Vertex 1 {m} + 17.4, 29.8, 44, !- X,Y,Z Vertex 2 {m} + 24, 29.8, 44, !- X,Y,Z Vertex 3 {m} + 24, 29.8, 49.5; !- X,Y,Z Vertex 4 {m} + +OS:Surface, + {ba5ad9c3-e3ba-48be-a5f8-f4c0c1f62141}, !- Handle + g_S2_wall, !- Name + Wall, !- Surface Type + , !- Construction Name + {63da0e2b-6e37-4b5e-adda-47a3a9f3b6bd}, !- Space Name + Outdoors, !- Outside Boundary Condition + , !- Outside Boundary Condition Object + SunExposed, !- Sun Exposure + WindExposed, !- Wind Exposure + , !- View Factor to Ground + , !- Number of Vertices + 24, 29.8, 49.5, !- X,Y,Z Vertex 1 {m} + 24, 29.8, 46.7, !- X,Y,Z Vertex 2 {m} + 28, 29.8, 46.7, !- X,Y,Z Vertex 3 {m} + 28, 29.8, 49.5; !- X,Y,Z Vertex 4 {m} + +OS:Surface, + {a3f4d26e-15ba-4c44-ba3a-4573aaeaf8fb}, !- Handle + g_S3_wall, !- Name + Wall, !- Surface Type + , !- Construction Name + {63da0e2b-6e37-4b5e-adda-47a3a9f3b6bd}, !- Space Name + Outdoors, !- Outside Boundary Condition + , !- Outside Boundary Condition Object + SunExposed, !- Sun Exposure + WindExposed, !- Wind Exposure + , !- View Factor to Ground + , !- Number of Vertices + 28, 29.8, 49.5, !- X,Y,Z Vertex 1 {m} + 28, 29.8, 44, !- X,Y,Z Vertex 2 {m} + 54, 29.8, 44, !- X,Y,Z Vertex 3 {m} + 54, 29.8, 49.5; !- X,Y,Z Vertex 4 {m} + +OS:SubSurface, + {04fbae1e-b248-472e-a102-6a9a4d44d817}, !- Handle + g_S3_door, !- Name + GlassDoor, !- Sub Surface Type + , !- Construction Name + {a3f4d26e-15ba-4c44-ba3a-4573aaeaf8fb}, !- Surface Name + , !- Outside Boundary Condition Object + , !- View Factor to Ground + , !- Shading Control Name + , !- Frame and Divider Name + , !- Multiplier + , !- Number of Vertices + 46.4, 29.8, 46, !- X,Y,Z Vertex 1 {m} + 46.4, 29.8, 44, !- X,Y,Z Vertex 2 {m} + 47.4, 29.8, 44, !- X,Y,Z Vertex 3 {m} + 47.4, 29.8, 46; !- X,Y,Z Vertex 4 {m} + +OS:Surface, + {4e03b7df-0561-41d7-ba37-36f4ea22b289}, !- Handle + g_top, !- Name + RoofCeiling, !- Surface Type + , !- Construction Name + {63da0e2b-6e37-4b5e-adda-47a3a9f3b6bd}, !- Space Name + Outdoors, !- Outside Boundary Condition + , !- Outside Boundary Condition Object + SunExposed, !- Sun Exposure + WindExposed, !- Wind Exposure + , !- View Factor to Ground + , !- Number of Vertices + 17.4, 40.2, 49.5, !- X,Y,Z Vertex 1 {m} + 17.4, 29.8, 49.5, !- X,Y,Z Vertex 2 {m} + 54, 29.8, 49.5, !- X,Y,Z Vertex 3 {m} + 54, 40.2, 49.5; !- X,Y,Z Vertex 4 {m} + +OS:SubSurface, + {10850ee7-5c1c-4e8f-861c-aac657058fc7}, !- Handle + g_sky, !- Name + Skylight, !- Sub Surface Type + , !- Construction Name + {4e03b7df-0561-41d7-ba37-36f4ea22b289}, !- Surface Name + , !- Outside Boundary Condition Object + , !- View Factor to Ground + , !- Shading Control Name + , !- Frame and Divider Name + , !- Multiplier + , !- Number of Vertices + 17.4, 40.2, 49.5, !- X,Y,Z Vertex 1 {m} + 17.4, 29.825, 49.5, !- X,Y,Z Vertex 2 {m} + 54, 29.825, 49.5, !- X,Y,Z Vertex 3 {m} + 54, 40.2, 49.5; !- X,Y,Z Vertex 4 {m} + +OS:Surface, + {6ec3d81f-de03-45ef-b297-e1dd87b6498c}, !- Handle + e_top, !- Name + RoofCeiling, !- Surface Type + , !- Construction Name + {63da0e2b-6e37-4b5e-adda-47a3a9f3b6bd}, !- Space Name + Outdoors, !- Outside Boundary Condition + , !- Outside Boundary Condition Object + SunExposed, !- Sun Exposure + WindExposed, !- Wind Exposure + , !- View Factor to Ground + , !- Number of Vertices + 24, 29.8, 46.7, !- X,Y,Z Vertex 1 {m} + 24, 28.3, 46.7, !- X,Y,Z Vertex 2 {m} + 28, 28.3, 46.7, !- X,Y,Z Vertex 3 {m} + 28, 29.8, 46.7; !- X,Y,Z Vertex 4 {m} + +OS:Surface, + {ab655635-ee4d-47e9-b436-a54a09d4af75}, !- Handle + e_floor, !- Name + Floor, !- Surface Type + {8509976d-6b3f-44db-b0a6-f9903e9a2412}, !- Construction Name + {63da0e2b-6e37-4b5e-adda-47a3a9f3b6bd}, !- Space Name + Outdoors, !- Outside Boundary Condition + , !- Outside Boundary Condition Object + SunExposed, !- Sun Exposure + WindExposed, !- Wind Exposure + , !- View Factor to Ground + , !- Number of Vertices + 24, 28.3, 40.8, !- X,Y,Z Vertex 1 {m} + 24, 29.8, 40.8, !- X,Y,Z Vertex 2 {m} + 28, 29.8, 40.8, !- X,Y,Z Vertex 3 {m} + 28, 28.3, 40.8; !- X,Y,Z Vertex 4 {m} + +OS:Surface, + {106961da-98dd-421e-bacc-dbf8cdae4886}, !- Handle + e_W_wall, !- Name + Wall, !- Surface Type + , !- Construction Name + {63da0e2b-6e37-4b5e-adda-47a3a9f3b6bd}, !- Space Name + Outdoors, !- Outside Boundary Condition + , !- Outside Boundary Condition Object + SunExposed, !- Sun Exposure + WindExposed, !- Wind Exposure + , !- View Factor to Ground + , !- Number of Vertices + 24, 29.8, 46.7, !- X,Y,Z Vertex 1 {m} + 24, 29.8, 40.8, !- X,Y,Z Vertex 2 {m} + 24, 28.3, 40.8, !- X,Y,Z Vertex 3 {m} + 24, 28.3, 46.7; !- X,Y,Z Vertex 4 {m} + +OS:Surface, + {dedb0792-4bab-49d1-97d0-25bdf0b249bd}, !- Handle + e_S_wall, !- Name + Wall, !- Surface Type + , !- Construction Name + {63da0e2b-6e37-4b5e-adda-47a3a9f3b6bd}, !- Space Name + Outdoors, !- Outside Boundary Condition + , !- Outside Boundary Condition Object + SunExposed, !- Sun Exposure + WindExposed, !- Wind Exposure + , !- View Factor to Ground + , !- Number of Vertices + 24, 28.3, 46.7, !- X,Y,Z Vertex 1 {m} + 24, 28.3, 40.8, !- X,Y,Z Vertex 2 {m} + 28, 28.3, 40.8, !- X,Y,Z Vertex 3 {m} + 28, 28.3, 46.7; !- X,Y,Z Vertex 4 {m} + +OS:Surface, + {35376737-2155-4e5c-90e1-a3597859ee61}, !- Handle + e_E_wall, !- Name + Wall, !- Surface Type + , !- Construction Name + {63da0e2b-6e37-4b5e-adda-47a3a9f3b6bd}, !- Space Name + Outdoors, !- Outside Boundary Condition + , !- Outside Boundary Condition Object + SunExposed, !- Sun Exposure + WindExposed, !- Wind Exposure + , !- View Factor to Ground + , !- Number of Vertices + 28, 28.3, 46.7, !- X,Y,Z Vertex 1 {m} + 28, 28.3, 40.8, !- X,Y,Z Vertex 2 {m} + 28, 29.8, 40.8, !- X,Y,Z Vertex 3 {m} + 28, 29.8, 46.7; !- X,Y,Z Vertex 4 {m} + +OS:Surface, + {fe3b5470-c1b0-4cd5-9076-e2bc50606c44}, !- Handle + e_N_wall, !- Name + Wall, !- Surface Type + , !- Construction Name + {63da0e2b-6e37-4b5e-adda-47a3a9f3b6bd}, !- Space Name + Outdoors, !- Outside Boundary Condition + , !- Outside Boundary Condition Object + SunExposed, !- Sun Exposure + WindExposed, !- Wind Exposure + , !- View Factor to Ground + , !- Number of Vertices + 28, 29.8, 42.406, !- X,Y,Z Vertex 1 {m} + 28, 29.8, 40.8, !- X,Y,Z Vertex 2 {m} + 24, 29.8, 40.8, !- X,Y,Z Vertex 3 {m} + 24, 29.8, 43.0075; !- X,Y,Z Vertex 4 {m} + +OS:Surface, + {5921c4b7-c10d-4307-bbe4-3084e326df4f}, !- Handle + e_p_wall, !- Name + Wall, !- Surface Type + , !- Construction Name + {63da0e2b-6e37-4b5e-adda-47a3a9f3b6bd}, !- Space Name + Surface, !- Outside Boundary Condition + {5adbd1a4-7207-40d5-a47d-a1e72ca6fdfa}, !- Outside Boundary Condition Object + NoSun, !- Sun Exposure + NoWind, !- Wind Exposure + , !- View Factor to Ground + , !- Number of Vertices + 28, 29.8, 44, !- X,Y,Z Vertex 1 {m} + 28, 29.8, 42.406, !- X,Y,Z Vertex 2 {m} + 24, 29.8, 43.0075, !- X,Y,Z Vertex 3 {m} + 24, 29.8, 44; !- X,Y,Z Vertex 4 {m} + +OS:Surface, + {9832bb4c-a62e-44c2-b510-1cdd3f8dedc6}, !- Handle + g_floor, !- Name + Floor, !- Surface Type + , !- Construction Name + {63da0e2b-6e37-4b5e-adda-47a3a9f3b6bd}, !- Space Name + Surface, !- Outside Boundary Condition + {d4c760bf-e02f-43b2-b8dd-800c9b7fd735}, !- Outside Boundary Condition Object + NoSun, !- Sun Exposure + NoWind, !- Wind Exposure + , !- View Factor to Ground + , !- Number of Vertices + 17.4, 29.8, 44, !- X,Y,Z Vertex 1 {m} + 17.4, 40.2, 44, !- X,Y,Z Vertex 2 {m} + 54, 40.2, 44, !- X,Y,Z Vertex 3 {m} + 54, 29.8, 44; !- X,Y,Z Vertex 4 {m} + +OS:Surface, + {d4c760bf-e02f-43b2-b8dd-800c9b7fd735}, !- Handle + p_top, !- Name + RoofCeiling, !- Surface Type + , !- Construction Name + {9c8278ca-49e4-41ac-b043-c8836480df0b}, !- Space Name + Surface, !- Outside Boundary Condition + {9832bb4c-a62e-44c2-b510-1cdd3f8dedc6}, !- Outside Boundary Condition Object + NoSun, !- Sun Exposure + NoWind, !- Wind Exposure + , !- View Factor to Ground + , !- Number of Vertices + 17.4, 40.2, 44, !- X,Y,Z Vertex 1 {m} + 17.4, 29.8, 44, !- X,Y,Z Vertex 2 {m} + 54, 29.8, 44, !- X,Y,Z Vertex 3 {m} + 54, 40.2, 44; !- X,Y,Z Vertex 4 {m} + +OS:Surface, + {5adbd1a4-7207-40d5-a47d-a1e72ca6fdfa}, !- Handle + p_e_wall, !- Name + Wall, !- Surface Type + , !- Construction Name + {9c8278ca-49e4-41ac-b043-c8836480df0b}, !- Space Name + Surface, !- Outside Boundary Condition + {5921c4b7-c10d-4307-bbe4-3084e326df4f}, !- Outside Boundary Condition Object + NoSun, !- Sun Exposure + NoWind, !- Wind Exposure + , !- View Factor to Ground + , !- Number of Vertices + 24, 29.8, 44, !- X,Y,Z Vertex 1 {m} + 24, 29.8, 43.0075, !- X,Y,Z Vertex 2 {m} + 28, 29.8, 42.406, !- X,Y,Z Vertex 3 {m} + 28, 29.8, 44; !- X,Y,Z Vertex 4 {m} + +OS:Surface, + {f038cd25-71fb-4a97-937c-01614eec66ad}, !- Handle + p_S1_wall, !- Name + Wall, !- Surface Type + , !- Construction Name + {9c8278ca-49e4-41ac-b043-c8836480df0b}, !- Space Name + Outdoors, !- Outside Boundary Condition + , !- Outside Boundary Condition Object + SunExposed, !- Sun Exposure + WindExposed, !- Wind Exposure + , !- View Factor to Ground + , !- Number of Vertices + 17.4, 29.8, 44, !- X,Y,Z Vertex 1 {m} + 24, 29.8, 43.0075, !- X,Y,Z Vertex 2 {m} + 24, 29.8, 44; !- X,Y,Z Vertex 3 {m} + +OS:Surface, + {6410c72c-817f-4d46-8ef4-7fab35fb8c6b}, !- Handle + p_S2_wall, !- Name + Wall, !- Surface Type + , !- Construction Name + {9c8278ca-49e4-41ac-b043-c8836480df0b}, !- Space Name + Outdoors, !- Outside Boundary Condition + , !- Outside Boundary Condition Object + SunExposed, !- Sun Exposure + WindExposed, !- Wind Exposure + , !- View Factor to Ground + , !- Number of Vertices + 28, 29.8, 44, !- X,Y,Z Vertex 1 {m} + 28, 29.8, 42.406, !- X,Y,Z Vertex 2 {m} + 30.7, 29.8, 42, !- X,Y,Z Vertex 3 {m} + 40.7, 29.8, 42, !- X,Y,Z Vertex 4 {m} + 54, 29.8, 44; !- X,Y,Z Vertex 5 {m} + +OS:Surface, + {52db64b6-7858-47b8-8946-51d5da36db9b}, !- Handle + p_N_wall, !- Name + Wall, !- Surface Type + , !- Construction Name + {9c8278ca-49e4-41ac-b043-c8836480df0b}, !- Space Name + Outdoors, !- Outside Boundary Condition + , !- Outside Boundary Condition Object + SunExposed, !- Sun Exposure + WindExposed, !- Wind Exposure + , !- View Factor to Ground + , !- Number of Vertices + 54, 40.2, 44, !- X,Y,Z Vertex 1 {m} + 40.7, 40.2, 42, !- X,Y,Z Vertex 2 {m} + 30.7, 40.2, 42, !- X,Y,Z Vertex 3 {m} + 17.4, 40.2, 44; !- X,Y,Z Vertex 4 {m} + +OS:Surface, + {dd038a70-b34b-4680-9ce5-a21513114959}, !- Handle + p_floor, !- Name + Floor, !- Surface Type + , !- Construction Name + {9c8278ca-49e4-41ac-b043-c8836480df0b}, !- Space Name + Outdoors, !- Outside Boundary Condition + , !- Outside Boundary Condition Object + SunExposed, !- Sun Exposure + WindExposed, !- Wind Exposure + , !- View Factor to Ground + , !- Number of Vertices + 30.7, 29.8, 42, !- X,Y,Z Vertex 1 {m} + 30.7, 40.2, 42, !- X,Y,Z Vertex 2 {m} + 40.7, 40.2, 42, !- X,Y,Z Vertex 3 {m} + 40.7, 29.8, 42; !- X,Y,Z Vertex 4 {m} + +OS:Surface, + {5f4c7827-6774-4f2e-9e2e-c43d61050b75}, !- Handle + p_E_floor, !- Name + Floor, !- Surface Type + , !- Construction Name + {9c8278ca-49e4-41ac-b043-c8836480df0b}, !- Space Name + Outdoors, !- Outside Boundary Condition + , !- Outside Boundary Condition Object + SunExposed, !- Sun Exposure + WindExposed, !- Wind Exposure + , !- View Factor to Ground + , !- Number of Vertices + 40.7, 29.8, 42, !- X,Y,Z Vertex 1 {m} + 40.7, 40.2, 42, !- X,Y,Z Vertex 2 {m} + 54, 40.2, 44, !- X,Y,Z Vertex 3 {m} + 54, 29.8, 44; !- X,Y,Z Vertex 4 {m} + +OS:Surface, + {9b4fb6df-ac14-46c5-af17-b62dd76b91cd}, !- Handle + p_W1_floor, !- Name + Floor, !- Surface Type + , !- Construction Name + {9c8278ca-49e4-41ac-b043-c8836480df0b}, !- Space Name + Outdoors, !- Outside Boundary Condition + , !- Outside Boundary Condition Object + SunExposed, !- Sun Exposure + WindExposed, !- Wind Exposure + , !- View Factor to Ground + , !- Number of Vertices + 17.4, 29.8, 44, !- X,Y,Z Vertex 1 {m} + 17.4, 40.2, 44, !- X,Y,Z Vertex 2 {m} + 24, 40.2, 43.0075, !- X,Y,Z Vertex 3 {m} + 24, 29.8, 43.0075; !- X,Y,Z Vertex 4 {m} + +OS:Surface, + {e7a2bb61-fb57-487a-93d9-d34f1f8cf785}, !- Handle + p_W2_floor, !- Name + Floor, !- Surface Type + , !- Construction Name + {9c8278ca-49e4-41ac-b043-c8836480df0b}, !- Space Name + Outdoors, !- Outside Boundary Condition + , !- Outside Boundary Condition Object + SunExposed, !- Sun Exposure + WindExposed, !- Wind Exposure + , !- View Factor to Ground + , !- Number of Vertices + 24, 29.8, 43.0075, !- X,Y,Z Vertex 1 {m} + 24, 33.1, 43.0075, !- X,Y,Z Vertex 2 {m} + 30.7, 33.1, 42, !- X,Y,Z Vertex 3 {m} + 30.7, 29.8, 42; !- X,Y,Z Vertex 4 {m} + +OS:Surface, + {38e8e5c9-1a1b-4dba-9faa-15969aac0838}, !- Handle + p_W3_floor, !- Name + Floor, !- Surface Type + , !- Construction Name + {9c8278ca-49e4-41ac-b043-c8836480df0b}, !- Space Name + Outdoors, !- Outside Boundary Condition + , !- Outside Boundary Condition Object + SunExposed, !- Sun Exposure + WindExposed, !- Wind Exposure + , !- View Factor to Ground + , !- Number of Vertices + 24, 36.9, 43.0075, !- X,Y,Z Vertex 1 {m} + 24, 40.2, 43.0075, !- X,Y,Z Vertex 2 {m} + 30.7, 40.2, 42, !- X,Y,Z Vertex 3 {m} + 30.7, 36.9, 42; !- X,Y,Z Vertex 4 {m} + +OS:Surface, + {bc23647e-8dbb-4628-a74d-81c66072197e}, !- Handle + p_W4_floor, !- Name + Floor, !- Surface Type + , !- Construction Name + {9c8278ca-49e4-41ac-b043-c8836480df0b}, !- Space Name + Outdoors, !- Outside Boundary Condition + , !- Outside Boundary Condition Object + SunExposed, !- Sun Exposure + WindExposed, !- Wind Exposure + , !- View Factor to Ground + , !- Number of Vertices + 29, 33.1, 42.2556, !- X,Y,Z Vertex 1 {m} + 29, 36.9, 42.2556, !- X,Y,Z Vertex 2 {m} + 30.7, 36.9, 42, !- X,Y,Z Vertex 3 {m} + 30.7, 33.1, 42; !- X,Y,Z Vertex 4 {m} + +OS:Surface, + {c5abe2db-1f82-4b04-b40c-78fee6a1f5a3}, !- Handle + s_W_wall, !- Name + Wall, !- Surface Type + , !- Construction Name + {9c8278ca-49e4-41ac-b043-c8836480df0b}, !- Space Name + Outdoors, !- Outside Boundary Condition + , !- Outside Boundary Condition Object + SunExposed, !- Sun Exposure + WindExposed, !- Wind Exposure + , !- View Factor to Ground + , !- Number of Vertices + 24, 36.9, 43.0075, !- X,Y,Z Vertex 1 {m} + 24, 36.9, 40.8, !- X,Y,Z Vertex 2 {m} + 24, 33.1, 40.8, !- X,Y,Z Vertex 3 {m} + 24, 33.1, 43.0075; !- X,Y,Z Vertex 4 {m} + +OS:Surface, + {12367fd3-98ff-49f8-a89c-b111de475527}, !- Handle + s_N_wall, !- Name + Wall, !- Surface Type + , !- Construction Name + {9c8278ca-49e4-41ac-b043-c8836480df0b}, !- Space Name + Outdoors, !- Outside Boundary Condition + , !- Outside Boundary Condition Object + SunExposed, !- Sun Exposure + WindExposed, !- Wind Exposure + , !- View Factor to Ground + , !- Number of Vertices + 29, 36.9, 42.2556, !- X,Y,Z Vertex 1 {m} + 29, 36.9, 40.8, !- X,Y,Z Vertex 2 {m} + 24, 36.9, 40.8, !- X,Y,Z Vertex 3 {m} + 24, 36.9, 43.0075; !- X,Y,Z Vertex 4 {m} + +OS:Surface, + {337ea1e8-f131-4e04-972b-c4f36602de3e}, !- Handle + s_E_wall, !- Name + Wall, !- Surface Type + , !- Construction Name + {9c8278ca-49e4-41ac-b043-c8836480df0b}, !- Space Name + Outdoors, !- Outside Boundary Condition + , !- Outside Boundary Condition Object + SunExposed, !- Sun Exposure + WindExposed, !- Wind Exposure + , !- View Factor to Ground + , !- Number of Vertices + 29, 33.1, 42.2556, !- X,Y,Z Vertex 1 {m} + 29, 33.1, 40.8, !- X,Y,Z Vertex 2 {m} + 29, 36.9, 40.8, !- X,Y,Z Vertex 3 {m} + 29, 36.9, 42.2556; !- X,Y,Z Vertex 4 {m} + +OS:Surface, + {cded6396-b54a-4632-9376-5b022f78e936}, !- Handle + s_S_wall, !- Name + Wall, !- Surface Type + , !- Construction Name + {9c8278ca-49e4-41ac-b043-c8836480df0b}, !- Space Name + Outdoors, !- Outside Boundary Condition + , !- Outside Boundary Condition Object + SunExposed, !- Sun Exposure + WindExposed, !- Wind Exposure + , !- View Factor to Ground + , !- Number of Vertices + 24, 33.1, 43.0075, !- X,Y,Z Vertex 1 {m} + 24, 33.1, 40.8, !- X,Y,Z Vertex 2 {m} + 29, 33.1, 40.8, !- X,Y,Z Vertex 3 {m} + 29, 33.1, 42.2556; !- X,Y,Z Vertex 4 {m} + +OS:Surface, + {fb2a9510-b4ea-487f-8862-34a806261768}, !- Handle + s_floor, !- Name + Floor, !- Surface Type + , !- Construction Name + {9c8278ca-49e4-41ac-b043-c8836480df0b}, !- Space Name + Outdoors, !- Outside Boundary Condition + , !- Outside Boundary Condition Object + SunExposed, !- Sun Exposure + WindExposed, !- Wind Exposure + , !- View Factor to Ground + , !- Number of Vertices + 24, 33.1, 40.8, !- X,Y,Z Vertex 1 {m} + 24, 36.9, 40.8, !- X,Y,Z Vertex 2 {m} + 29, 36.9, 40.8, !- X,Y,Z Vertex 3 {m} + 29, 33.1, 40.8; !- X,Y,Z Vertex 4 {m} + +OS:ThermalZone, + {b4818e3a-f20f-41fe-8df2-235cccc8fece}, !- Handle + scrigno_plenum|zone, !- Name + , !- Multiplier + , !- Ceiling Height {m} + , !- Volume {m3} + , !- Floor Area {m2} + , !- Zone Inside Convection Algorithm + , !- Zone Outside Convection Algorithm + , !- Zone Conditioning Equipment List Name + {acb7d221-4e06-44f6-b574-18aa14a2e90a}, !- Zone Air Inlet Port List + {0b86391f-b9c3-481d-969d-7b39341ec6c0}, !- Zone Air Exhaust Port List + {6fa79706-2fcf-4638-86a6-6d70997b1d8c}, !- Zone Air Node Name + {79fd39e5-ce28-4bbd-b176-a6e00c21dea7}, !- Zone Return Air Port List + , !- Primary Daylighting Control Name + , !- Fraction of Zone Controlled by Primary Daylighting Control + , !- Secondary Daylighting Control Name + , !- Fraction of Zone Controlled by Secondary Daylighting Control + , !- Illuminance Map Name + , !- Group Rendering Name + , !- Thermostat Name + No; !- Use Ideal Air Loads + +OS:Node, + {23ee1f05-c1ee-4ba8-80da-a4abf90eb373}, !- Handle + Node 1, !- Name + {6fa79706-2fcf-4638-86a6-6d70997b1d8c}, !- Inlet Port + ; !- Outlet Port + +OS:Connection, + {6fa79706-2fcf-4638-86a6-6d70997b1d8c}, !- Handle + {2ea343ae-3dda-4abc-82ba-fdda54214d58}, !- Name + {b4818e3a-f20f-41fe-8df2-235cccc8fece}, !- Source Object + 11, !- Outlet Port + {23ee1f05-c1ee-4ba8-80da-a4abf90eb373}, !- Target Object + 2; !- Inlet Port + +OS:PortList, + {acb7d221-4e06-44f6-b574-18aa14a2e90a}, !- Handle + {9a89a585-b5ef-413c-a12a-ecf4950fbcb1}, !- Name + {b4818e3a-f20f-41fe-8df2-235cccc8fece}; !- HVAC Component + +OS:PortList, + {0b86391f-b9c3-481d-969d-7b39341ec6c0}, !- Handle + {fd184db1-2ab0-4713-a7bd-1a84f7114601}, !- Name + {b4818e3a-f20f-41fe-8df2-235cccc8fece}; !- HVAC Component + +OS:PortList, + {79fd39e5-ce28-4bbd-b176-a6e00c21dea7}, !- Handle + {0de9df32-7c92-4bac-8e99-483bd5f0453b}, !- Name + {b4818e3a-f20f-41fe-8df2-235cccc8fece}; !- HVAC Component + +OS:Sizing:Zone, + {ec709111-7c41-4049-9542-7fc0eda5553b}, !- Handle + {b4818e3a-f20f-41fe-8df2-235cccc8fece}, !- Zone or ZoneList Name + SupplyAirTemperature, !- Zone Cooling Design Supply Air Temperature Input Method + 14, !- Zone Cooling Design Supply Air Temperature {C} + 11.11, !- Zone Cooling Design Supply Air Temperature Difference {deltaC} + SupplyAirTemperature, !- Zone Heating Design Supply Air Temperature Input Method + 40, !- Zone Heating Design Supply Air Temperature {C} + 11.11, !- Zone Heating Design Supply Air Temperature Difference {deltaC} + 0.0085, !- Zone Cooling Design Supply Air Humidity Ratio {kg-H2O/kg-air} + 0.008, !- Zone Heating Design Supply Air Humidity Ratio {kg-H2O/kg-air} + , !- Zone Heating Sizing Factor + , !- Zone Cooling Sizing Factor + DesignDay, !- Cooling Design Air Flow Method + , !- Cooling Design Air Flow Rate {m3/s} + , !- Cooling Minimum Air Flow per Zone Floor Area {m3/s-m2} + , !- Cooling Minimum Air Flow {m3/s} + , !- Cooling Minimum Air Flow Fraction + DesignDay, !- Heating Design Air Flow Method + , !- Heating Design Air Flow Rate {m3/s} + , !- Heating Maximum Air Flow per Zone Floor Area {m3/s-m2} + , !- Heating Maximum Air Flow {m3/s} + , !- Heating Maximum Air Flow Fraction + , !- Design Zone Air Distribution Effectiveness in Cooling Mode + , !- Design Zone Air Distribution Effectiveness in Heating Mode + No, !- Account for Dedicated Outdoor Air System + NeutralSupplyAir, !- Dedicated Outdoor Air System Control Strategy + autosize, !- Dedicated Outdoor Air Low Setpoint Temperature for Design {C} + autosize; !- Dedicated Outdoor Air High Setpoint Temperature for Design {C} + +OS:ZoneHVAC:EquipmentList, + {9c9b4bf9-9831-4331-a3c2-eff44726ce30}, !- Handle + Zone HVAC Equipment List 1, !- Name + {b4818e3a-f20f-41fe-8df2-235cccc8fece}; !- Thermal Zone + +OS:ThermalZone, + {63115b0c-6adf-4949-9f4d-d50da862eeb0}, !- Handle + scrigno_gallery|zone, !- Name + , !- Multiplier + , !- Ceiling Height {m} + , !- Volume {m3} + , !- Floor Area {m2} + , !- Zone Inside Convection Algorithm + , !- Zone Outside Convection Algorithm + , !- Zone Conditioning Equipment List Name + {eb8754c0-c8e8-49cd-95a4-44078b6f84cd}, !- Zone Air Inlet Port List + {5ba9dfbd-8dbf-4a4c-8b38-0fa228601786}, !- Zone Air Exhaust Port List + {a5d38443-7546-49b4-9499-cc470c6277c5}, !- Zone Air Node Name + {c6f63882-1355-441d-a96f-e7861378a8d7}, !- Zone Return Air Port List + , !- Primary Daylighting Control Name + , !- Fraction of Zone Controlled by Primary Daylighting Control + , !- Secondary Daylighting Control Name + , !- Fraction of Zone Controlled by Secondary Daylighting Control + , !- Illuminance Map Name + , !- Group Rendering Name + , !- Thermostat Name + No; !- Use Ideal Air Loads + +OS:Node, + {c618bc86-69c5-4424-8c46-cc9cc6973f12}, !- Handle + Node 2, !- Name + {a5d38443-7546-49b4-9499-cc470c6277c5}, !- Inlet Port + ; !- Outlet Port + +OS:Connection, + {a5d38443-7546-49b4-9499-cc470c6277c5}, !- Handle + {e00e0616-1727-4a49-b80f-f8602a317e6f}, !- Name + {63115b0c-6adf-4949-9f4d-d50da862eeb0}, !- Source Object + 11, !- Outlet Port + {c618bc86-69c5-4424-8c46-cc9cc6973f12}, !- Target Object + 2; !- Inlet Port + +OS:PortList, + {eb8754c0-c8e8-49cd-95a4-44078b6f84cd}, !- Handle + {21d47737-2cbb-4815-9e77-a672f3007f94}, !- Name + {63115b0c-6adf-4949-9f4d-d50da862eeb0}; !- HVAC Component + +OS:PortList, + {5ba9dfbd-8dbf-4a4c-8b38-0fa228601786}, !- Handle + {51d5f60e-3034-46c6-a6fe-68ef7342078b}, !- Name + {63115b0c-6adf-4949-9f4d-d50da862eeb0}; !- HVAC Component + +OS:PortList, + {c6f63882-1355-441d-a96f-e7861378a8d7}, !- Handle + {b268622e-34e3-4a5f-98d0-33b1cb2e3392}, !- Name + {63115b0c-6adf-4949-9f4d-d50da862eeb0}; !- HVAC Component + +OS:Sizing:Zone, + {853eba1a-fd20-400f-b97e-9b48b5e509e1}, !- Handle + {63115b0c-6adf-4949-9f4d-d50da862eeb0}, !- Zone or ZoneList Name + SupplyAirTemperature, !- Zone Cooling Design Supply Air Temperature Input Method + 14, !- Zone Cooling Design Supply Air Temperature {C} + 11.11, !- Zone Cooling Design Supply Air Temperature Difference {deltaC} + SupplyAirTemperature, !- Zone Heating Design Supply Air Temperature Input Method + 40, !- Zone Heating Design Supply Air Temperature {C} + 11.11, !- Zone Heating Design Supply Air Temperature Difference {deltaC} + 0.0085, !- Zone Cooling Design Supply Air Humidity Ratio {kg-H2O/kg-air} + 0.008, !- Zone Heating Design Supply Air Humidity Ratio {kg-H2O/kg-air} + , !- Zone Heating Sizing Factor + , !- Zone Cooling Sizing Factor + DesignDay, !- Cooling Design Air Flow Method + , !- Cooling Design Air Flow Rate {m3/s} + , !- Cooling Minimum Air Flow per Zone Floor Area {m3/s-m2} + , !- Cooling Minimum Air Flow {m3/s} + , !- Cooling Minimum Air Flow Fraction + DesignDay, !- Heating Design Air Flow Method + , !- Heating Design Air Flow Rate {m3/s} + , !- Heating Maximum Air Flow per Zone Floor Area {m3/s-m2} + , !- Heating Maximum Air Flow {m3/s} + , !- Heating Maximum Air Flow Fraction + , !- Design Zone Air Distribution Effectiveness in Cooling Mode + , !- Design Zone Air Distribution Effectiveness in Heating Mode + No, !- Account for Dedicated Outdoor Air System + NeutralSupplyAir, !- Dedicated Outdoor Air System Control Strategy + autosize, !- Dedicated Outdoor Air Low Setpoint Temperature for Design {C} + autosize; !- Dedicated Outdoor Air High Setpoint Temperature for Design {C} + +OS:ZoneHVAC:EquipmentList, + {72dd257f-deb2-428f-9493-2abb1dec3baf}, !- Handle + Zone HVAC Equipment List 2, !- Name + {63115b0c-6adf-4949-9f4d-d50da862eeb0}; !- Thermal Zone diff --git a/spec/tbd_tests_spec.rb b/spec/tbd_tests_spec.rb index 885b65f..076ba40 100644 --- a/spec/tbd_tests_spec.rb +++ b/spec/tbd_tests_spec.rb @@ -17,1859 +17,17 @@ RMIN = TBD::RMIN.dup RMAX = TBD::RMAX.dup - it "can process thermal bridging and derating: LoScrigno" do + it "can check for balcony sills (ASHRAE 90.1 2022)" do + translator = OpenStudio::OSVersion::VersionTranslator.new expect(TBD.level ).to eq(INF) expect(TBD.reset(DBG)).to eq(DBG) expect(TBD.level ).to eq(DBG) expect(TBD.clean! ).to eq(DBG) - # The following populates OpenStudio and Topolys models of "Lo Scrigno" - # (or Jewel Box), by Renzo Piano (Lingotto Factory, Turin); a cantilevered, - # single space art gallery (space #1) above a supply plenum with slanted - # undersides (space #2), and resting on four main pillars. - - # The first ~800 lines generate the OpenStudio model from scratch, relying - # the OpenStudio SDK and SketchUp-fed 3D surface vertices. It would be - # easier to simply read in the saved .osm file (1x-time generation) of the - # model. The generation code is maintained as is for debugging purposes - # (e.g. SketchUp-reported vertices are +/- accurate). The remaining 1/3 - # of this first RSpec reproduces TBD's 'process' method. It is repeated - # step-by-step here for detailed testing purposes. - model = OpenStudio::Model::Model.new - building = model.getBuilding - - os_s = OpenStudio::Model::ShadingSurfaceGroup.new(model) - # For the purposes of the RSpec, vertical access (elevator and stairs, - # normally fully glazed) are modelled as (opaque) extensions of either - # space. Surfaces are prefixed as follows: - # - "g_" : art gallery - # - "p_" : underfloor plenum (supplying gallery) - # - "s_" : stairwell (leading to/through plenum & gallery) - # - "e_" : (side) elevator leading to gallery - os_g = OpenStudio::Model::Space.new(model) # gallery & elevator - os_p = OpenStudio::Model::Space.new(model) # plenum & stairwell - os_g.setName("scrigno_gallery") - os_p.setName( "scrigno_plenum") - - # For the purposes of the spec, all opaque envelope assemblies are built up - # from a single, 3-layered construction. All subsurfaces are Simple Glazing - # constructions. - construction = OpenStudio::Model::Construction.new(model) - fenestration = OpenStudio::Model::Construction.new(model) - elevator = OpenStudio::Model::Construction.new(model) - shadez = OpenStudio::Model::Construction.new(model) - glazing = OpenStudio::Model::SimpleGlazing.new(model) - exterior = OpenStudio::Model::MasslessOpaqueMaterial.new(model) - xps8x25mm = OpenStudio::Model::MasslessOpaqueMaterial.new(model) - insulation = OpenStudio::Model::StandardOpaqueMaterial.new(model) - interior = OpenStudio::Model::StandardOpaqueMaterial.new(model) - - construction.setName("scrigno_construction") - fenestration.setName("scrigno_fen") - elevator.setName("elevator") - shadez.setName("scrigno_shading") - glazing.setName("scrigno_glazing") - exterior.setName("scrigno_exterior") - xps8x25mm.setName("xps8x25mm") - insulation.setName("scrigno_insulation") - interior.setName("scrigno_interior") - - # Material properties. - expect(exterior.setRoughness("Rough" )).to be true - expect(insulation.setRoughness("MediumRough")).to be true - expect(interior.setRoughness("MediumRough" )).to be true - expect(xps8x25mm.setRoughness("Rough" )).to be true - - expect(glazing.setUFactor( 2.0000)).to be true - expect(glazing.setSolarHeatGainCoefficient(0.5000)).to be true - expect(glazing.setVisibleTransmittance( 0.7000)).to be true - - expect(exterior.setThermalResistance( 0.3626)).to be true - expect(exterior.setThermalAbsorptance( 0.9000)).to be true - expect(exterior.setSolarAbsorptance( 0.7000)).to be true - expect(exterior.setVisibleAbsorptance( 0.7000)).to be true - - expect(insulation.setThickness( 0.1184)).to be true - expect(insulation.setConductivity( 0.0450)).to be true - expect(insulation.setDensity( 265.0000)).to be true - expect(insulation.setSpecificHeat( 836.8000)).to be true - expect(insulation.setThermalAbsorptance( 0.9000)).to be true - expect(insulation.setSolarAbsorptance( 0.7000)).to be true - expect(insulation.setVisibleAbsorptance( 0.7000)).to be true - - expect(interior.setThickness( 0.0126)).to be true - expect(interior.setConductivity( 0.1600)).to be true - expect(interior.setDensity( 784.9000)).to be true - expect(interior.setSpecificHeat( 830.0000)).to be true - expect(interior.setThermalAbsorptance( 0.9000)).to be true - expect(interior.setSolarAbsorptance( 0.9000)).to be true - expect(interior.setVisibleAbsorptance( 0.9000)).to be true - - expect(xps8x25mm.setThermalResistance( 8 * 0.8800)).to be true - expect(xps8x25mm.setThermalAbsorptance( 0.9000)).to be true - expect(xps8x25mm.setSolarAbsorptance( 0.7000)).to be true - expect(xps8x25mm.setVisibleAbsorptance( 0.7000)).to be true - - # Layered constructions. - layers = OpenStudio::Model::MaterialVector.new - layers << glazing - expect(fenestration.setLayers(layers)).to be true - - layers = OpenStudio::Model::MaterialVector.new - layers << exterior - layers << insulation - layers << interior - expect(construction.setLayers(layers)).to be true - - layers = OpenStudio::Model::MaterialVector.new - layers << exterior - layers << xps8x25mm - layers << interior - expect(elevator.setLayers(layers)).to be true - - layers = OpenStudio::Model::MaterialVector.new - layers << exterior - expect(shadez.setLayers(layers)).to be true - - defaults = OpenStudio::Model::DefaultSurfaceConstructions.new(model) - subs = OpenStudio::Model::DefaultSubSurfaceConstructions.new(model) - set = OpenStudio::Model::DefaultConstructionSet.new(model) - - expect(defaults.setWallConstruction( construction)).to be true - expect(defaults.setRoofCeilingConstruction( construction)).to be true - expect(defaults.setFloorConstruction( construction)).to be true - expect(subs.setFixedWindowConstruction( fenestration)).to be true - expect(subs.setOperableWindowConstruction( fenestration)).to be true - expect(subs.setDoorConstruction( fenestration)).to be true - expect(subs.setGlassDoorConstruction( fenestration)).to be true - expect(subs.setOverheadDoorConstruction( fenestration)).to be true - expect(subs.setSkylightConstruction( fenestration)).to be true - expect(set.setAdiabaticSurfaceConstruction( construction)).to be true - expect(set.setInteriorPartitionConstruction( construction)).to be true - expect(set.setDefaultExteriorSurfaceConstructions(defaults)).to be true - expect(set.setDefaultInteriorSurfaceConstructions(defaults)).to be true - expect(set.setDefaultInteriorSubSurfaceConstructions( subs)).to be true - expect(set.setDefaultExteriorSubSurfaceConstructions( subs)).to be true - expect(set.setSpaceShadingConstruction( shadez)).to be true - expect(set.setBuildingShadingConstruction( shadez)).to be true - expect(set.setSiteShadingConstruction( shadez)).to be true - expect(building.setDefaultConstructionSet( set)).to be true - - # Set building shading surfaces: - # (4x above gallery roof + 2x North/South balconies) - os_v = OpenStudio::Point3dVector.new - os_v << OpenStudio::Point3d.new( 12.4, 45.0, 50.0) - os_v << OpenStudio::Point3d.new( 12.4, 25.0, 50.0) - os_v << OpenStudio::Point3d.new( 22.7, 25.0, 50.0) - os_v << OpenStudio::Point3d.new( 22.7, 45.0, 50.0) - - os_r1_shade = OpenStudio::Model::ShadingSurface.new(os_v, model) - os_r1_shade.setName("r1_shade") - expect(os_r1_shade.setShadingSurfaceGroup(os_s)).to be true - - os_v = OpenStudio::Point3dVector.new - os_v << OpenStudio::Point3d.new( 22.7, 45.0, 50.0) - os_v << OpenStudio::Point3d.new( 22.7, 37.5, 50.0) - os_v << OpenStudio::Point3d.new( 48.7, 37.5, 50.0) - os_v << OpenStudio::Point3d.new( 48.7, 45.0, 50.0) - - os_r2_shade = OpenStudio::Model::ShadingSurface.new(os_v, model) - os_r2_shade.setName("r2_shade") - expect(os_r2_shade.setShadingSurfaceGroup(os_s)).to be true - - os_v = OpenStudio::Point3dVector.new - os_v << OpenStudio::Point3d.new( 22.7, 32.5, 50.0) - os_v << OpenStudio::Point3d.new( 22.7, 25.0, 50.0) - os_v << OpenStudio::Point3d.new( 48.7, 25.0, 50.0) - os_v << OpenStudio::Point3d.new( 48.7, 32.5, 50.0) - - os_r3_shade = OpenStudio::Model::ShadingSurface.new(os_v, model) - os_r3_shade.setName("r3_shade") - expect(os_r3_shade.setShadingSurfaceGroup(os_s)).to be true - - os_v = OpenStudio::Point3dVector.new - os_v << OpenStudio::Point3d.new( 48.7, 45.0, 50.0) - os_v << OpenStudio::Point3d.new( 48.7, 25.0, 50.0) - os_v << OpenStudio::Point3d.new( 59.0, 25.0, 50.0) - os_v << OpenStudio::Point3d.new( 59.0, 45.0, 50.0) - - os_r4_shade = OpenStudio::Model::ShadingSurface.new(os_v, model) - os_r4_shade.setName("r4_shade") - expect(os_r4_shade.setShadingSurfaceGroup(os_s)).to be true - - os_v = OpenStudio::Point3dVector.new - os_v << OpenStudio::Point3d.new( 47.4, 40.2, 44.0) - os_v << OpenStudio::Point3d.new( 47.4, 41.7, 44.0) - os_v << OpenStudio::Point3d.new( 45.7, 41.7, 44.0) - os_v << OpenStudio::Point3d.new( 45.7, 40.2, 44.0) - - os_N_balcony = OpenStudio::Model::ShadingSurface.new(os_v, model) - os_N_balcony.setName("N_balcony") # 1.70m as thermal bridge - expect(os_N_balcony.setShadingSurfaceGroup(os_s)).to be true - - os_v = OpenStudio::Point3dVector.new - os_v << OpenStudio::Point3d.new( 28.1, 29.8, 44.0) - os_v << OpenStudio::Point3d.new( 28.1, 28.3, 44.0) - os_v << OpenStudio::Point3d.new( 47.4, 28.3, 44.0) - os_v << OpenStudio::Point3d.new( 47.4, 29.8, 44.0) - - os_S_balcony = OpenStudio::Model::ShadingSurface.new(os_v, model) - os_S_balcony.setName("S_balcony") # 19.3m as thermal bridge - expect(os_S_balcony.setShadingSurfaceGroup(os_s)).to be true - - # 1st space: gallery (g) with elevator (e) surfaces - os_v = OpenStudio::Point3dVector.new - os_v << OpenStudio::Point3d.new( 17.4, 40.2, 49.5) - os_v << OpenStudio::Point3d.new( 17.4, 40.2, 44.0) - os_v << OpenStudio::Point3d.new( 17.4, 29.8, 44.0) - os_v << OpenStudio::Point3d.new( 17.4, 29.8, 49.5) - - os_g_W_wall = OpenStudio::Model::Surface.new(os_v, model) - os_g_W_wall.setName("g_W_wall") - expect(os_g_W_wall.setSpace(os_g)).to be true - expect(os_g_W_wall.surfaceType.downcase).to eq("wall") - expect(os_g_W_wall.isConstructionDefaulted).to be true - - c = set.getDefaultConstruction(os_g_W_wall).get.to_LayeredConstruction.get - expect(c.numLayers).to eq(3) - expect(c.isOpaque).to be true - expect(c.nameString).to eq("scrigno_construction") - - os_v = OpenStudio::Point3dVector.new - os_v << OpenStudio::Point3d.new( 54.0, 40.2, 49.5) - os_v << OpenStudio::Point3d.new( 54.0, 40.2, 44.0) - os_v << OpenStudio::Point3d.new( 17.4, 40.2, 44.0) - os_v << OpenStudio::Point3d.new( 17.4, 40.2, 49.5) - - os_g_N_wall = OpenStudio::Model::Surface.new(os_v, model) - os_g_N_wall.setName("g_N_wall") - expect(os_g_N_wall.setSpace(os_g)).to be true - expect(os_g_N_wall.uFactor).to_not be_empty - expect(os_g_N_wall.uFactor.get).to be_within(TOL).of(0.31) - - os_v = OpenStudio::Point3dVector.new - os_v << OpenStudio::Point3d.new( 47.4, 40.2, 46.0) - os_v << OpenStudio::Point3d.new( 47.4, 40.2, 44.0) - os_v << OpenStudio::Point3d.new( 46.4, 40.2, 44.0) - os_v << OpenStudio::Point3d.new( 46.4, 40.2, 46.0) - - os_g_N_door = OpenStudio::Model::SubSurface.new(os_v, model) - os_g_N_door.setName("g_N_door") - expect(os_g_N_door.setSubSurfaceType("GlassDoor")).to be true - expect(os_g_N_door.setSurface(os_g_N_wall)).to be true - expect(os_g_N_door.uFactor).to_not be_empty - expect(os_g_N_door.uFactor.get).to be_within(TOL).of(2.00) - - os_v = OpenStudio::Point3dVector.new - os_v << OpenStudio::Point3d.new( 54.0, 29.8, 49.5) - os_v << OpenStudio::Point3d.new( 54.0, 29.8, 44.0) - os_v << OpenStudio::Point3d.new( 54.0, 40.2, 44.0) - os_v << OpenStudio::Point3d.new( 54.0, 40.2, 49.5) - - os_g_E_wall = OpenStudio::Model::Surface.new(os_v, model) - os_g_E_wall.setName("g_E_wall") - expect(os_g_E_wall.setSpace(os_g)).to be true - - os_v = OpenStudio::Point3dVector.new - os_v << OpenStudio::Point3d.new( 17.4, 29.8, 49.5) - os_v << OpenStudio::Point3d.new( 17.4, 29.8, 44.0) - os_v << OpenStudio::Point3d.new( 24.0, 29.8, 44.0) - os_v << OpenStudio::Point3d.new( 24.0, 29.8, 49.5) - - os_g_S1_wall = OpenStudio::Model::Surface.new(os_v, model) - os_g_S1_wall.setName("g_S1_wall") - expect(os_g_S1_wall.setSpace(os_g)).to be true - expect(os_g_S1_wall.uFactor).to_not be_empty - expect(os_g_S1_wall.uFactor.get).to be_within(TOL).of(0.31) - - os_v = OpenStudio::Point3dVector.new - os_v << OpenStudio::Point3d.new( 24.0, 29.8, 49.5) - os_v << OpenStudio::Point3d.new( 24.0, 29.8, 46.7) - os_v << OpenStudio::Point3d.new( 28.0, 29.8, 46.7) - os_v << OpenStudio::Point3d.new( 28.0, 29.8, 49.5) - - os_g_S2_wall = OpenStudio::Model::Surface.new(os_v, model) - os_g_S2_wall.setName("g_S2_wall") - expect(os_g_S2_wall.setSpace(os_g)).to be true - expect(os_g_S2_wall.uFactor).to_not be_empty - expect(os_g_S2_wall.uFactor.get).to be_within(TOL).of(0.31) - - os_v = OpenStudio::Point3dVector.new - os_v << OpenStudio::Point3d.new( 28.0, 29.8, 49.5) - os_v << OpenStudio::Point3d.new( 28.0, 29.8, 44.0) - os_v << OpenStudio::Point3d.new( 54.0, 29.8, 44.0) - os_v << OpenStudio::Point3d.new( 54.0, 29.8, 49.5) - - os_g_S3_wall = OpenStudio::Model::Surface.new(os_v, model) - os_g_S3_wall.setName("g_S3_wall") - expect(os_g_S3_wall.setSpace(os_g)).to be true - expect(os_g_S3_wall.uFactor).to_not be_empty - expect(os_g_S3_wall.uFactor.get).to be_within(TOL).of(0.31) - - os_v = OpenStudio::Point3dVector.new - os_v << OpenStudio::Point3d.new( 46.4, 29.8, 46.0) - os_v << OpenStudio::Point3d.new( 46.4, 29.8, 44.0) - os_v << OpenStudio::Point3d.new( 47.4, 29.8, 44.0) - os_v << OpenStudio::Point3d.new( 47.4, 29.8, 46.0) - - os_g_S3_door = OpenStudio::Model::SubSurface.new(os_v, model) - os_g_S3_door.setName("g_S3_door") - expect(os_g_S3_door.setSubSurfaceType("GlassDoor")).to be true - expect(os_g_S3_door.setSurface(os_g_S3_wall)).to be true - expect(os_g_S3_door.uFactor).to_not be_empty - expect(os_g_S3_door.uFactor.get).to be_within(TOL).of(2.00) - - os_v = OpenStudio::Point3dVector.new - os_v << OpenStudio::Point3d.new( 17.4, 40.2, 49.5) - os_v << OpenStudio::Point3d.new( 17.4, 29.8, 49.5) - os_v << OpenStudio::Point3d.new( 54.0, 29.8, 49.5) - os_v << OpenStudio::Point3d.new( 54.0, 40.2, 49.5) - - os_g_top = OpenStudio::Model::Surface.new(os_v, model) - os_g_top.setName("g_top") - expect(os_g_top.setSpace(os_g)).to be true - expect(os_g_top.uFactor).to_not be_empty - expect(os_g_top.uFactor.get).to be_within(TOL).of(0.31) - expect(os_g_top.surfaceType.downcase).to eq("roofceiling") - expect(os_g_top.isConstructionDefaulted).to be true - - c = set.getDefaultConstruction(os_g_top).get.to_LayeredConstruction.get - expect(c.numLayers).to eq(3) - expect(c.isOpaque).to be true - expect(c.nameString).to eq("scrigno_construction") - - # Leaving a 1" strip of rooftop (0.915 m2) so roof m2 > skylight m2. - os_v = OpenStudio::Point3dVector.new - os_v << OpenStudio::Point3d.new( 17.4, 40.2 , 49.5) - os_v << OpenStudio::Point3d.new( 17.4, 29.8 + 0.025, 49.5) - os_v << OpenStudio::Point3d.new( 54.0, 29.8 + 0.025, 49.5) - os_v << OpenStudio::Point3d.new( 54.0, 40.2 , 49.5) - - os_g_sky = OpenStudio::Model::SubSurface.new(os_v, model) - os_g_sky.setName("g_sky") - expect(os_g_sky.setSurface(os_g_top)).to be true - - os_v = OpenStudio::Point3dVector.new - os_v << OpenStudio::Point3d.new( 24.0, 29.8, 46.7) - os_v << OpenStudio::Point3d.new( 24.0, 28.3, 46.7) - os_v << OpenStudio::Point3d.new( 28.0, 28.3, 46.7) - os_v << OpenStudio::Point3d.new( 28.0, 29.8, 46.7) - - os_e_top = OpenStudio::Model::Surface.new(os_v, model) - os_e_top.setName("e_top") - expect(os_e_top.setSpace(os_g)).to be true - - os_v = OpenStudio::Point3dVector.new - os_v << OpenStudio::Point3d.new( 24.0, 28.3, 40.8) - os_v << OpenStudio::Point3d.new( 24.0, 29.8, 40.8) - os_v << OpenStudio::Point3d.new( 28.0, 29.8, 40.8) - os_v << OpenStudio::Point3d.new( 28.0, 28.3, 40.8) - - os_e_floor = OpenStudio::Model::Surface.new(os_v, model) - os_e_floor.setName("e_floor") - expect(os_e_floor.setSpace(os_g)).to be true - expect(os_e_floor.setOutsideBoundaryCondition("Outdoors")).to be true - expect(os_e_floor.surfaceType.downcase).to eq("floor") - expect(os_e_floor.isConstructionDefaulted).to be true - - c = set.getDefaultConstruction(os_e_floor).get.to_LayeredConstruction.get - expect(c.numLayers).to eq(3) - expect(c.isOpaque).to be true - expect(c.nameString).to eq("scrigno_construction") - expect(os_e_floor.setConstruction(elevator)).to be true - expect(os_e_floor.isConstructionDefaulted).to be false - - c = os_e_floor.construction.get.to_LayeredConstruction.get - expect(c.numLayers).to eq(3) - expect(c.isOpaque).to be true - expect(c.nameString).to eq("elevator") - - os_v = OpenStudio::Point3dVector.new - os_v << OpenStudio::Point3d.new( 24.0, 29.8, 46.7) - os_v << OpenStudio::Point3d.new( 24.0, 29.8, 40.8) - os_v << OpenStudio::Point3d.new( 24.0, 28.3, 40.8) - os_v << OpenStudio::Point3d.new( 24.0, 28.3, 46.7) - - os_e_W_wall = OpenStudio::Model::Surface.new(os_v, model) - os_e_W_wall.setName("e_W_wall") - expect(os_e_W_wall.setSpace(os_g)).to be true - - os_v = OpenStudio::Point3dVector.new - os_v << OpenStudio::Point3d.new( 24.0, 28.3, 46.7) - os_v << OpenStudio::Point3d.new( 24.0, 28.3, 40.8) - os_v << OpenStudio::Point3d.new( 28.0, 28.3, 40.8) - os_v << OpenStudio::Point3d.new( 28.0, 28.3, 46.7) - - os_e_S_wall = OpenStudio::Model::Surface.new(os_v, model) - os_e_S_wall.setName("e_S_wall") - expect(os_e_S_wall.setSpace(os_g)).to be true - - os_v = OpenStudio::Point3dVector.new - os_v << OpenStudio::Point3d.new( 28.0, 28.3, 46.7) - os_v << OpenStudio::Point3d.new( 28.0, 28.3, 40.8) - os_v << OpenStudio::Point3d.new( 28.0, 29.8, 40.8) - os_v << OpenStudio::Point3d.new( 28.0, 29.8, 46.7) - - os_e_E_wall = OpenStudio::Model::Surface.new(os_v, model) - os_e_E_wall.setName("e_E_wall") - expect(os_e_E_wall.setSpace(os_g)).to be true - - os_v = OpenStudio::Point3dVector.new - os_v << OpenStudio::Point3d.new( 28.0, 29.8, 42.4060) - os_v << OpenStudio::Point3d.new( 28.0, 29.8, 40.8000) - os_v << OpenStudio::Point3d.new( 24.0, 29.8, 40.8000) - os_v << OpenStudio::Point3d.new( 24.0, 29.8, 43.0075) - - os_e_N_wall = OpenStudio::Model::Surface.new(os_v, model) - os_e_N_wall.setName("e_N_wall") - expect(os_e_N_wall.setSpace(os_g)).to be true - - os_v = OpenStudio::Point3dVector.new - os_v << OpenStudio::Point3d.new( 28.0, 29.8, 44.0000) - os_v << OpenStudio::Point3d.new( 28.0, 29.8, 42.4060) - os_v << OpenStudio::Point3d.new( 24.0, 29.8, 43.0075) - os_v << OpenStudio::Point3d.new( 24.0, 29.8, 44.0000) - - os_e_p_wall = OpenStudio::Model::Surface.new(os_v, model) - os_e_p_wall.setName("e_p_wall") - expect(os_e_p_wall.setSpace(os_g)).to be true - - os_v = OpenStudio::Point3dVector.new - os_v << OpenStudio::Point3d.new( 17.4, 29.8, 44.0) - os_v << OpenStudio::Point3d.new( 17.4, 40.2, 44.0) - os_v << OpenStudio::Point3d.new( 54.0, 40.2, 44.0) - os_v << OpenStudio::Point3d.new( 54.0, 29.8, 44.0) - - os_g_floor = OpenStudio::Model::Surface.new(os_v, model) - os_g_floor.setName("g_floor") - expect(os_g_floor.setSpace(os_g) ).to be true - - # 2nd space: plenum (p) with stairwell (s) surfaces - os_v = OpenStudio::Point3dVector.new - os_v << OpenStudio::Point3d.new( 17.4, 40.2, 44.0) - os_v << OpenStudio::Point3d.new( 17.4, 29.8, 44.0) - os_v << OpenStudio::Point3d.new( 54.0, 29.8, 44.0) - os_v << OpenStudio::Point3d.new( 54.0, 40.2, 44.0) - - os_p_top = OpenStudio::Model::Surface.new(os_v, model) - os_p_top.setName("p_top") - expect(os_p_top.setSpace(os_p)).to be true - expect(os_p_top.setAdjacentSurface(os_g_floor)).to be true - expect(os_g_floor.setAdjacentSurface(os_p_top)).to be true - expect(os_p_top.setOutsideBoundaryCondition( "Surface")).to be true - expect(os_g_floor.setOutsideBoundaryCondition("Surface")).to be true - - os_v = OpenStudio::Point3dVector.new - os_v << OpenStudio::Point3d.new( 24.0, 29.8, 44.0000) - os_v << OpenStudio::Point3d.new( 24.0, 29.8, 43.0075) - os_v << OpenStudio::Point3d.new( 28.0, 29.8, 42.4060) - os_v << OpenStudio::Point3d.new( 28.0, 29.8, 44.0000) - - os_p_e_wall = OpenStudio::Model::Surface.new(os_v, model) - os_p_e_wall.setName("p_e_wall") - expect(os_p_e_wall.setSpace(os_p)).to be true - expect(os_e_p_wall.setAdjacentSurface(os_p_e_wall)).to be true - expect(os_p_e_wall.setAdjacentSurface(os_e_p_wall)).to be true - expect(os_p_e_wall.setOutsideBoundaryCondition("Surface")).to be true - expect(os_e_p_wall.setOutsideBoundaryCondition("Surface")).to be true - - os_v = OpenStudio::Point3dVector.new - os_v << OpenStudio::Point3d.new( 17.4, 29.8, 44.0000) - os_v << OpenStudio::Point3d.new( 24.0, 29.8, 43.0075) - os_v << OpenStudio::Point3d.new( 24.0, 29.8, 44.0000) - - os_p_S1_wall = OpenStudio::Model::Surface.new(os_v, model) - os_p_S1_wall.setName("p_S1_wall") - expect(os_p_S1_wall.setSpace(os_p)).to be true - - os_v = OpenStudio::Point3dVector.new - os_v << OpenStudio::Point3d.new( 28.0, 29.8, 44.0000) - os_v << OpenStudio::Point3d.new( 28.0, 29.8, 42.4060) - os_v << OpenStudio::Point3d.new( 30.7, 29.8, 42.0000) - os_v << OpenStudio::Point3d.new( 40.7, 29.8, 42.0000) - os_v << OpenStudio::Point3d.new( 54.0, 29.8, 44.0000) - - os_p_S2_wall = OpenStudio::Model::Surface.new(os_v, model) - os_p_S2_wall.setName("p_S2_wall") - expect(os_p_S2_wall.setSpace(os_p)).to be true - - os_v = OpenStudio::Point3dVector.new - os_v << OpenStudio::Point3d.new( 54.0, 40.2, 44.0) - os_v << OpenStudio::Point3d.new( 40.7, 40.2, 42.0) - os_v << OpenStudio::Point3d.new( 30.7, 40.2, 42.0) - os_v << OpenStudio::Point3d.new( 17.4, 40.2, 44.0) - - os_p_N_wall = OpenStudio::Model::Surface.new(os_v, model) - os_p_N_wall.setName("p_N_wall") - expect(os_p_N_wall.setSpace(os_p)).to be true - - os_v = OpenStudio::Point3dVector.new - os_v << OpenStudio::Point3d.new( 30.7, 29.8, 42.0) - os_v << OpenStudio::Point3d.new( 30.7, 40.2, 42.0) - os_v << OpenStudio::Point3d.new( 40.7, 40.2, 42.0) - os_v << OpenStudio::Point3d.new( 40.7, 29.8, 42.0) - - os_p_floor = OpenStudio::Model::Surface.new(os_v, model) - os_p_floor.setName("p_floor") - expect(os_p_floor.setSpace(os_p)).to be true - expect(os_p_floor.setOutsideBoundaryCondition("Outdoors")).to be true - - os_v = OpenStudio::Point3dVector.new - os_v << OpenStudio::Point3d.new( 40.7, 29.8, 42.0) - os_v << OpenStudio::Point3d.new( 40.7, 40.2, 42.0) - os_v << OpenStudio::Point3d.new( 54.0, 40.2, 44.0) - os_v << OpenStudio::Point3d.new( 54.0, 29.8, 44.0) - - os_p_E_floor = OpenStudio::Model::Surface.new(os_v, model) - os_p_E_floor.setName("p_E_floor") - expect(os_p_E_floor.setSpace(os_p)).to be true - expect(os_p_E_floor.setSurfaceType("Floor")).to be true # walls by default - - os_v = OpenStudio::Point3dVector.new - os_v << OpenStudio::Point3d.new( 17.4, 29.8, 44.0000) - os_v << OpenStudio::Point3d.new( 17.4, 40.2, 44.0000) - os_v << OpenStudio::Point3d.new( 24.0, 40.2, 43.0075) - os_v << OpenStudio::Point3d.new( 24.0, 29.8, 43.0075) - - os_p_W1_floor = OpenStudio::Model::Surface.new(os_v, model) - os_p_W1_floor.setName("p_W1_floor") - expect(os_p_W1_floor.setSpace(os_p)).to be true - expect(os_p_W1_floor.setSurfaceType("Floor")).to be true - - os_v = OpenStudio::Point3dVector.new - os_v << OpenStudio::Point3d.new( 24.0, 29.8, 43.0075) - os_v << OpenStudio::Point3d.new( 24.0, 33.1, 43.0075) - os_v << OpenStudio::Point3d.new( 30.7, 33.1, 42.0000) - os_v << OpenStudio::Point3d.new( 30.7, 29.8, 42.0000) - - os_p_W2_floor = OpenStudio::Model::Surface.new(os_v, model) - os_p_W2_floor.setName("p_W2_floor") - expect(os_p_W2_floor.setSpace(os_p)).to be true - expect(os_p_W2_floor.setSurfaceType("Floor")).to be true - - os_v = OpenStudio::Point3dVector.new - os_v << OpenStudio::Point3d.new( 24.0, 36.9, 43.0075) - os_v << OpenStudio::Point3d.new( 24.0, 40.2, 43.0075) - os_v << OpenStudio::Point3d.new( 30.7, 40.2, 42.0000) - os_v << OpenStudio::Point3d.new( 30.7, 36.9, 42.0000) - - os_p_W3_floor = OpenStudio::Model::Surface.new(os_v, model) - os_p_W3_floor.setName("p_W3_floor") - expect(os_p_W3_floor.setSpace(os_p)).to be true - expect(os_p_W3_floor.setSurfaceType("Floor")).to be true - - os_v = OpenStudio::Point3dVector.new - os_v << OpenStudio::Point3d.new( 29.0, 33.1, 42.2556) - os_v << OpenStudio::Point3d.new( 29.0, 36.9, 42.2556) - os_v << OpenStudio::Point3d.new( 30.7, 36.9, 42.0000) - os_v << OpenStudio::Point3d.new( 30.7, 33.1, 42.0000) - - os_p_W4_floor = OpenStudio::Model::Surface.new(os_v, model) - os_p_W4_floor.setName("p_W4_floor") - expect(os_p_W4_floor.setSpace(os_p)).to be true - expect(os_p_W4_floor.setSurfaceType("Floor")).to be true - - os_v = OpenStudio::Point3dVector.new - os_v << OpenStudio::Point3d.new( 24.0, 36.9, 43.0075) - os_v << OpenStudio::Point3d.new( 24.0, 36.9, 40.8000) - os_v << OpenStudio::Point3d.new( 24.0, 33.1, 40.8000) - os_v << OpenStudio::Point3d.new( 24.0, 33.1, 43.0075) - - os_s_W_wall = OpenStudio::Model::Surface.new(os_v, model) - os_s_W_wall.setName("s_W_wall") - expect(os_s_W_wall.setSpace(os_p)).to be true - - os_v = OpenStudio::Point3dVector.new - os_v << OpenStudio::Point3d.new( 29.0, 36.9, 42.2556) - os_v << OpenStudio::Point3d.new( 29.0, 36.9, 40.8000) - os_v << OpenStudio::Point3d.new( 24.0, 36.9, 40.8000) - os_v << OpenStudio::Point3d.new( 24.0, 36.9, 43.0075) - - os_s_N_wall = OpenStudio::Model::Surface.new(os_v, model) - os_s_N_wall.setName("s_N_wall") - expect(os_s_N_wall.setSpace(os_p)).to be true - - os_v = OpenStudio::Point3dVector.new - os_v << OpenStudio::Point3d.new( 29.0, 33.1, 42.2556) - os_v << OpenStudio::Point3d.new( 29.0, 33.1, 40.8000) - os_v << OpenStudio::Point3d.new( 29.0, 36.9, 40.8000) - os_v << OpenStudio::Point3d.new( 29.0, 36.9, 42.2556) - - os_s_E_wall = OpenStudio::Model::Surface.new(os_v, model) - os_s_E_wall.setName("s_E_wall") - expect(os_s_E_wall.setSpace(os_p)).to be true - - os_v = OpenStudio::Point3dVector.new - os_v << OpenStudio::Point3d.new( 24.0, 33.1, 43.0075) - os_v << OpenStudio::Point3d.new( 24.0, 33.1, 40.8000) - os_v << OpenStudio::Point3d.new( 29.0, 33.1, 40.8000) - os_v << OpenStudio::Point3d.new( 29.0, 33.1, 42.2556) - - os_s_S_wall = OpenStudio::Model::Surface.new(os_v, model) - os_s_S_wall.setName("s_S_wall") - expect(os_s_S_wall.setSpace(os_p)).to be true - - os_v = OpenStudio::Point3dVector.new - os_v << OpenStudio::Point3d.new( 24.0, 33.1, 40.8) - os_v << OpenStudio::Point3d.new( 24.0, 36.9, 40.8) - os_v << OpenStudio::Point3d.new( 29.0, 36.9, 40.8) - os_v << OpenStudio::Point3d.new( 29.0, 33.1, 40.8) - - os_s_floor = OpenStudio::Model::Surface.new(os_v, model) - os_s_floor.setName("s_floor") - expect(os_s_floor.setSpace(os_p)).to be true - expect(os_s_floor.setSurfaceType("Floor")).to be true - expect(os_s_floor.setOutsideBoundaryCondition("Outdoors")).to be true - - # Assign thermal zones. - model.getSpaces.each do |space| - zone = OpenStudio::Model::ThermalZone.new(model) - zone.setName("#{space.nameString}|zone") - space.setThermalZone(zone) - end - - pth = File.join(__dir__, "files/osms/out/loscrigno.osm") - model.save(pth, true) - - - t_model = Topolys::Model.new - argh = { setpoints: false, parapet: true } - surfaces = {} - - model.getSurfaces.sort_by { |s| s.nameString }.each do |s| - surface = TBD.properties(s, argh) - expect(surface).to_not be_nil - expect(surface).to be_a(Hash) - expect(surface).to have_key(:space) - - surfaces[s.nameString] = surface - end - - expect(surfaces.size).to eq(31) - - surfaces.each do |id, surface| - expect(surface[:conditioned]).to be true - expect(surface).to have_key(:heating) - expect(surface).to have_key(:cooling) - end - - surfaces.each do |id, surface| - expect(surface).to_not have_key(:deratable) - surface[:deratable] = false - next if surface[:ground ] - next unless surface[:conditioned] - - unless surface[:boundary] == "outdoors" - next unless surfaces.key?(surface[:boundary]) - next if surfaces[surface[:boundary]][:conditioned] - end - - expect(surface).to have_key(:index) - surface[:deratable] = true - end - - [:windows, :doors, :skylights].each do |holes| # sort kids - surfaces.values.each do |surface| - next unless surface.key?(holes) - - surface[holes] = surface[holes].sort_by { |_, s| s[:minz] }.to_h - end - end - - expect(surfaces["g_top" ]).to have_key(:type) - expect(surfaces["g_S1_wall"]).to have_key(:type) - expect(surfaces["g_S2_wall"]).to have_key(:type) - expect(surfaces["g_S3_wall"]).to have_key(:type) - expect(surfaces["g_N_wall" ]).to have_key(:type) - - expect(surfaces["g_top" ]).to have_key(:skylights) - expect(surfaces["g_top" ]).to_not have_key(:windows) - expect(surfaces["g_top" ]).to_not have_key(:doors) - - expect(surfaces["g_S1_wall"]).to_not have_key(:skylights) - expect(surfaces["g_S1_wall"]).to_not have_key(:windows) - expect(surfaces["g_S1_wall"]).to_not have_key(:doors) - - expect(surfaces["g_S2_wall"]).to_not have_key(:skylights) - expect(surfaces["g_S2_wall"]).to_not have_key(:windows) - expect(surfaces["g_S2_wall"]).to_not have_key(:doors) - - expect(surfaces["g_S3_wall"]).to_not have_key(:skylights) - expect(surfaces["g_S3_wall"]).to_not have_key(:windows) - expect(surfaces["g_S3_wall"]).to have_key(:doors) - - expect(surfaces["g_N_wall"]).to_not have_key(:skylights) - expect(surfaces["g_N_wall"]).to_not have_key(:windows) - expect(surfaces["g_N_wall"]).to have_key(:doors) - - expect(surfaces["g_top" ][:skylights].size).to eq(1) - expect(surfaces["g_S3_wall"][:doors ].size).to eq(1) - expect(surfaces["g_N_wall" ][:doors ].size).to eq(1) - expect(surfaces["g_top" ][:skylights]).to have_key("g_sky") - expect(surfaces["g_S3_wall"][:doors ]).to have_key("g_S3_door") - expect(surfaces["g_N_wall" ][:doors ]).to have_key("g_N_door") - - # Split "surfaces" hash into "floors", "ceilings" and "walls" hashes. - floors = surfaces.select { |_, s| s[:type] == :floor } - ceilings = surfaces.select { |_, s| s[:type] == :ceiling } - walls = surfaces.select { |_, s| s[:type] == :wall } - - floors = floors.sort_by { |_, s| [s[:minz], s[:space]] }.to_h - ceilings = ceilings.sort_by { |_, s| [s[:minz], s[:space]] }.to_h - walls = walls.sort_by { |_, s| [s[:minz], s[:space]] }.to_h - - expect(floors.size ).to eq( 9) # 7 - expect(ceilings.size).to eq( 3) - expect(walls.size ).to eq(19) # 17 - - # Fetch OpenStudio shading surfaces & key attributes. - shades = {} - - model.getShadingSurfaces.each do |s| - expect(s.shadingSurfaceGroup).to_not be_empty - id = s.nameString - group = s.shadingSurfaceGroup.get - shading = group.to_ShadingSurfaceGroup - tr = TBD.transforms(group) - - expect(tr).to be_a(Hash) - expect(tr).to have_key(:t) - expect(tr).to have_key(:r) - t = tr[:t] - r = tr[:r] - expect(t).to_not be_nil - expect(r).to_not be_nil - - expect(shading).to_not be_empty - empty = shading.get.space.empty? - r += shading.get.space.get.directionofRelativeNorth unless empty - n = TBD.trueNormal(s, r) - expect(n).to_not be_nil - - points = (t * s.vertices).map{ |v| Topolys::Point3D.new(v.x, v.y, v.z) } - - minz = (points.map{ |p| p.z }).min - - shades[id] = { group: group, points: points, minz: minz, n: n } - end - - expect(shades.size).to eq(6) - - # Mutually populate TBD & Topolys surfaces. Keep track of created "holes". - holes = {} - floor_holes = TBD.dads(t_model, floors) - ceiling_holes = TBD.dads(t_model, ceilings) - wall_holes = TBD.dads(t_model, walls) - - holes.merge!(floor_holes) - holes.merge!(ceiling_holes) - holes.merge!(wall_holes) - - expect(floor_holes ).to be_empty - expect(ceiling_holes.size).to eq(1) - expect(wall_holes.size ).to eq(2) - expect(holes.size ).to eq(3) - - floors.values.each do |props| # testing normals - t_x = props[:face].outer.plane.normal.x - t_y = props[:face].outer.plane.normal.y - t_z = props[:face].outer.plane.normal.z - - expect(props[:n].x).to be_within(0.001).of(t_x) - expect(props[:n].y).to be_within(0.001).of(t_y) - expect(props[:n].z).to be_within(0.001).of(t_z) - end - - # OpenStudio (opaque) surfaces VS number of Topolys (opaque) faces. - expect(surfaces.size ).to eq(31) - expect(t_model.faces.size).to eq(31) - - TBD.dads(t_model, shades) - expect(t_model.faces.size).to eq(37) - - # Loop through Topolys edges and populate TBD edge hash. Initially, there - # should be a one-to-one correspondence between Topolys and TBD edge - # objects. Use Topolys-generated identifiers as unique edge hash keys. - edges = {} - - holes.each do |id, wire| # start with hole edges - wire.edges.each do |e| - i = e.id - l = e.length - ex = edges.key?(i) - - edges[i] = { length: l, v0: e.v0, v1: e.v1, surfaces: {} } unless ex - - next if edges[i][:surfaces].key?(wire.attributes[:id]) - - edges[i][:surfaces][wire.attributes[:id]] = { wire: wire.id } - end - end - - expect(edges.size).to eq(12) - - # Next, floors, ceilings & walls; then shades. - TBD.faces(floors, edges) - - expect(edges.size).to eq(51) - - TBD.faces(ceilings, edges) - expect(edges.size).to eq(60) - - TBD.faces(walls, edges) - expect(edges.size).to eq(78) - - TBD.faces(shades, edges) - expect( edges.size).to eq(100) - expect(t_model.edges.size).to eq(100) - - # edges.values.each do |edge| - # puts "#{'%5.2f' % edge[:length]}m #{edge[:surfaces].keys.to_a}" - # end - # 10.38m ["g_sky", "g_top", "g_W_wall"] - # 36.60m ["g_sky", "g_top"] - # 10.38m ["g_sky", "g_top", "g_E_wall"] - # 36.60m ["g_sky", "g_top", "g_N_wall"] - # 2.00m ["g_N_door", "g_N_wall"] - # 1.00m ["g_N_door", "g_floor", "p_top", "p_N_wall", "g_N_wall", "N_balcony"] - # 2.00m ["g_N_door", "g_N_wall"] - # 1.00m ["g_N_door", "g_N_wall"] - # 2.00m ["g_S3_door", "g_S3_wall"] - # 1.00m ["g_S3_door", "g_floor", "p_top", "p_S2_wall", "g_S3_wall", "S_balcony"] - # 2.00m ["g_S3_door", "g_S3_wall"] - # 1.00m ["g_S3_door", "g_S3_wall"] - # 1.50m ["e_floor", "e_W_wall"] - # 4.00m ["e_floor", "e_N_wall"] - # 1.50m ["e_floor", "e_E_wall"] - # 4.00m ["e_floor", "e_S_wall"] - # 3.80m ["s_floor", "s_W_wall"] - # 5.00m ["s_floor", "s_N_wall"] - # 3.80m ["s_floor", "s_E_wall"] - # 5.00m ["s_floor", "s_S_wall"] - # 10.40m ["p_E_floor", "p_floor"] - # 13.45m ["p_E_floor", "p_N_wall"] - # 10.40m ["p_E_floor", "g_floor", "p_top", "g_E_wall"] - # 13.45m ["p_E_floor", "p_S2_wall"] - # 3.30m ["p_W2_floor", "p_W1_floor"] - # 5.06m ["p_W2_floor", "s_S_wall"] - # 1.72m ["p_W2_floor", "p_W4_floor"] - # 3.30m ["p_W2_floor", "p_floor"] - # 2.73m ["p_W2_floor", "p_S2_wall"] - # 4.04m ["p_W2_floor", "e_N_wall", "e_p_wall", "p_e_wall"] - # 3.80m ["p_floor", "p_W4_floor"] - # 3.30m ["p_floor", "p_W3_floor"] - # 10.00m ["p_floor", "p_N_wall"] - # 10.00m ["p_floor", "p_S2_wall"] - # 3.80m ["p_W4_floor", "s_E_wall"] - # 1.72m ["p_W4_floor", "p_W3_floor"] - # 3.30m ["p_W3_floor", "p_W1_floor"] - # 6.78m ["p_W3_floor", "p_N_wall"] - # 5.06m ["p_W3_floor", "s_N_wall"] - # 10.40m ["p_W1_floor", "g_floor", "p_top", "g_W_wall"] - # 6.67m ["p_W1_floor", "p_N_wall"] - # 3.80m ["p_W1_floor", "s_W_wall"] - # 6.67m ["p_W1_floor", "p_S1_wall"] - # 28.30m ["g_floor", "p_top", "p_N_wall", "g_N_wall"] - # 0.70m ["g_floor", "p_top", "p_N_wall", "g_N_wall", "N_balcony"] - # 6.60m ["g_floor", "p_top", "p_N_wall", "g_N_wall"] - # 6.60m ["g_floor", "p_top", "p_S2_wall", "g_S3_wall"] - # 18.30m ["g_floor", "p_top", "p_S2_wall", "g_S3_wall", "S_balcony"] - # 0.10m ["g_floor", "p_top", "p_S2_wall", "g_S3_wall"] - # 4.00m ["g_floor", "p_top", "e_p_wall", "p_e_wall"] - # 6.60m ["g_floor", "p_top", "p_S1_wall", "g_S1_wall"] - # 1.50m ["e_top", "e_W_wall"] - # 4.00m ["e_top", "e_S_wall"] - # 1.50m ["e_top", "e_E_wall"] - # 4.00m ["e_top", "g_S2_wall"] - # 0.02m ["g_top", "g_W_wall"] - # 6.60m ["g_top", "g_S1_wall"] - # 4.00m ["g_top", "g_S2_wall"] - # 26.00m ["g_top", "g_S3_wall"] - # 0.02m ["g_top", "g_E_wall"] - # 5.90m ["e_E_wall", "e_S_wall"] - # 1.61m ["e_E_wall", "e_N_wall"] - # 1.59m ["e_E_wall", "p_S2_wall", "e_p_wall", "p_e_wall"] - # 2.70m ["e_E_wall", "g_S3_wall"] - # 2.21m ["e_N_wall", "e_W_wall"] - # 5.90m ["e_S_wall", "e_W_wall"] - # 2.70m ["e_W_wall", "g_S1_wall"] - # 0.99m ["e_W_wall", "e_p_wall", "p_e_wall", "p_S1_wall"] - # 2.21m ["s_S_wall", "s_W_wall"] - # 1.46m ["s_S_wall", "s_E_wall"] - # 1.46m ["s_N_wall", "s_E_wall"] - # 2.21m ["s_N_wall", "s_W_wall"] - # 5.50m ["g_W_wall", "g_N_wall"] - # 5.50m ["g_W_wall", "g_S1_wall"] - # 2.80m ["g_S1_wall", "g_S2_wall"] - # 5.50m ["g_N_wall", "g_E_wall"] - # 5.50m ["g_E_wall", "g_S3_wall"] - # 2.80m ["g_S3_wall", "g_S2_wall"] - # 1.50m ["S_balcony"] - # 19.30m ["S_balcony"] - # 1.50m ["S_balcony"] - # 1.50m ["N_balcony"] - # 1.70m ["N_balcony"] - # 1.50m ["N_balcony"] - # 7.50m ["r3_shade", "r1_shade"] - # 26.00m ["r3_shade"] - # 7.50m ["r3_shade", "r4_shade"] - # 26.00m ["r3_shade"] - # 7.50m ["r2_shade", "r1_shade"] - # 26.00m ["r2_shade"] - # 7.50m ["r2_shade", "r4_shade"] - # 26.00m ["r2_shade"] - # 5.00m ["r4_shade"] - # 10.30m ["r4_shade"] - # 20.00m ["r4_shade"] - # 10.30m ["r4_shade"] - # 20.00m ["r1_shade"] - # 10.30m ["r1_shade"] - # 5.00m ["r1_shade"] - # 10.30m ["r1_shade"] - - # The following surfaces should all share an edge. - p_S2_wall_face = walls["p_S2_wall"][:face] - e_p_wall_face = walls["e_p_wall" ][:face] - p_e_wall_face = walls["p_e_wall" ][:face] - e_E_wall_face = walls["e_E_wall" ][:face] - - p_S2_wall_edge_ids = Set.new(p_S2_wall_face.outer.edges.map{ |oe| oe.id} ) - e_p_wall_edges_ids = Set.new( e_p_wall_face.outer.edges.map{ |oe| oe.id} ) - p_e_wall_edges_ids = Set.new( p_e_wall_face.outer.edges.map{ |oe| oe.id} ) - e_E_wall_edges_ids = Set.new( e_E_wall_face.outer.edges.map{ |oe| oe.id} ) - - intersection = p_S2_wall_edge_ids & - e_p_wall_edges_ids & - p_e_wall_edges_ids - expect(intersection.size).to eq(1) - - intersection = p_S2_wall_edge_ids & - e_p_wall_edges_ids & - p_e_wall_edges_ids & - e_E_wall_edges_ids - expect(intersection.size).to eq(1) - - shared_edges = p_S2_wall_face.shared_outer_edges(e_p_wall_face) - expect(shared_edges.size).to eq(1) - expect(shared_edges.first.id).to eq(intersection.to_a.first) - - shared_edges = p_S2_wall_face.shared_outer_edges(p_e_wall_face) - expect(shared_edges.size).to eq(1) - expect(shared_edges.first.id).to eq(intersection.to_a.first) - - shared_edges = p_S2_wall_face.shared_outer_edges(e_E_wall_face) - expect(shared_edges.size).to eq(1) - expect(shared_edges.first.id).to eq(intersection.to_a.first) - - # g_floor and p_top should be connected with all edges shared - g_floor_face = floors["g_floor"][:face] - p_top_face = ceilings["p_top"][:face] - g_floor_wire = g_floor_face.outer - g_floor_edges = g_floor_wire.edges - p_top_wire = p_top_face.outer - p_top_edges = p_top_wire.edges - shared_edges = p_top_face.shared_outer_edges(g_floor_face) - - expect(g_floor_edges.size).to be > 4 - expect(g_floor_edges.size).to eq(p_top_edges.size) - expect( shared_edges.size).to eq(p_top_edges.size) - - g_floor_edges.each do |g_floor_edge| - expect(p_top_edges.find { |e| e.id == g_floor_edge.id } ).to be_truthy - end - - expect(floors.size ).to eq( 9) - expect(ceilings.size).to eq( 3) - expect(walls.size ).to eq(19) - expect(shades.size ).to eq( 6) - - zenith = Topolys::Vector3D.new(0, 0, 1).freeze - north = Topolys::Vector3D.new(0, 1, 0).freeze - east = Topolys::Vector3D.new(1, 0, 0).freeze - - edges.values.each do |edge| - origin = edge[:v0].point - terminal = edge[:v1].point - dx = (origin.x - terminal.x).abs - dy = (origin.y - terminal.y).abs - dz = (origin.z - terminal.z).abs - horizontal = dz.abs < TOL - vertical = dx < TOL && dy < TOL - edge_V = terminal - origin - expect(edge_V.magnitude > TOL).to be true - edge_plane = Topolys::Plane3D.new(origin, edge_V) - - if vertical - reference_V = north.dup - elsif horizontal - reference_V = zenith.dup - else - reference = edge_plane.project(origin + zenith) - reference_V = reference - origin - end - - edge[:surfaces].each do |id, surface| - t_model.wires.each do |wire| - next unless surface[:wire] == wire.id - - normal = surfaces[id][:n] if surfaces.key?(id) - normal = holes[id].attributes[:n] if holes.key?(id) - normal = shades[id][:n] if shades.key?(id) - farthest = Topolys::Point3D.new(origin.x, origin.y, origin.z) - farthest_V = farthest - origin - inverted = false - i_origin = wire.points.index(origin) - i_terminal = wire.points.index(terminal) - i_last = wire.points.size - 1 - - if i_terminal == 0 - inverted = true unless i_origin == i_last - elsif i_origin == i_last - inverted = true unless i_terminal == 0 - else - inverted = true unless i_terminal - i_origin == 1 - end - - wire.points.each do |point| - next if point == origin - next if point == terminal - - point_on_plane = edge_plane.project(point) - origin_point_V = point_on_plane - origin - point_V_magnitude = origin_point_V.magnitude - next unless point_V_magnitude > TOL - - if inverted - plane = Topolys::Plane3D.from_points(terminal, origin, point) - else - plane = Topolys::Plane3D.from_points(origin, terminal, point) - end - - dnx = (normal.x - plane.normal.x).abs - dny = (normal.y - plane.normal.y).abs - dnz = (normal.z - plane.normal.z).abs - next unless dnx < TOL && dny < TOL && dnz < TOL - - farther = point_V_magnitude > farthest_V.magnitude - farthest = point if farther - farthest_V = origin_point_V if farther - end - - angle = edge_V.angle(farthest_V) - expect(angle).to be_within(TOL).of(Math::PI / 2) - angle = reference_V.angle(farthest_V) - - adjust = false - - if vertical - adjust = true if east.dot(farthest_V) < -TOL - else - dN = north.dot(farthest_V) - dN1 = north.dot(farthest_V).abs - 1 - - if dN.abs < TOL || dN1.abs < TOL - adjust = true if east.dot(farthest_V) < -TOL - else - adjust = true if dN < -TOL - end - end - - angle = 2 * Math::PI - angle if adjust - angle -= 2 * Math::PI if (angle - 2 * Math::PI).abs < TOL - surface[:angle ] = angle - farthest_V.normalize! - surface[:polar ] = farthest_V - surface[:normal] = normal - end # end of edge-linked, surface-to-wire loop - end # end of edge-linked surface loop - - edge[:horizontal] = horizontal - edge[:vertical ] = vertical - edge[:surfaces ] = edge[:surfaces].sort_by{ |i, p| p[:angle] }.to_h - end # end of edge loop - - expect(edges.size ).to eq(100) - expect(t_model.edges.size).to eq(100) - - argh[:option] = "poor (BETBG)" - expect(argh.size).to eq(3) - - json = TBD.inputs(surfaces, edges, argh) - expect(TBD.status).to be_zero - expect(TBD.logs).to be_empty - - expect(argh.size).to eq(5) - expect(argh).to have_key(:option) - expect(argh).to have_key(:setpoints) - expect(argh).to have_key(:parapet) - expect(argh).to have_key(:io_path) - expect(argh).to have_key(:schema_path) - - expect(argh[:option ]).to eq("poor (BETBG)") - expect(argh[:setpoints ]).to be false - expect(argh[:parapet ]).to be true - expect(argh[:io_path ]).to be_nil - expect(argh[:schema_path]).to be_nil - - expect(json).to be_a(Hash) - expect(json).to have_key(:psi) - expect(json).to have_key(:khi) - expect(json).to have_key(:io) - - expect(json[:psi]).to be_a(TBD::PSI) - expect(json[:khi]).to be_a(TBD::KHI) - expect(json[:io ]).to_not be_empty - expect(json[:io ]).to have_key(:building) - expect(json[:io ][:building]).to have_key(:psi) - - psi = json[:io][:building][:psi] - shorts = json[:psi].shorthands(psi) - expect(shorts[:has]).to_not be_empty - expect(shorts[:val]).to_not be_empty - - edges.values.each do |edge| - next unless edge.key?(:surfaces) - - deratables = [] - set = {} - - edge[:surfaces].keys.each do |id| - next unless surfaces.key?(id) - - deratables << id if surfaces[id][:deratable] - end - - next if deratables.empty? - - edge[:surfaces].keys.each do |id| - next unless surfaces.key?(id) - next unless deratables.include?(id) - - # Evaluate current set content before processing a new linked surface. - is = {} - is[:head ] = set.keys.to_s.include?("head") - is[:sill ] = set.keys.to_s.include?("sill") - is[:jamb ] = set.keys.to_s.include?("jamb") - is[:doorhead ] = set.keys.to_s.include?("doorhead") - is[:doorsill ] = set.keys.to_s.include?("doorsill") - is[:doorjamb ] = set.keys.to_s.include?("doorjamb") - is[:skylighthead] = set.keys.to_s.include?("skylighthead") - is[:skylightsill] = set.keys.to_s.include?("skylightsill") - is[:skylightjamb] = set.keys.to_s.include?("skylightjamb") - is[:spandrel ] = set.keys.to_s.include?("spandrel") - is[:corner ] = set.keys.to_s.include?("corner") - is[:parapet ] = set.keys.to_s.include?("parapet") - is[:roof ] = set.keys.to_s.include?("roof") - is[:party ] = set.keys.to_s.include?("party") - is[:grade ] = set.keys.to_s.include?("grade") - is[:balcony ] = set.keys.to_s.include?("balcony") - is[:balconysill ] = set.keys.to_s.include?("balconysill") - is[:rimjoist ] = set.keys.to_s.include?("rimjoist") - - # Label edge as ... - # :head, :sill, :jamb (vertical fenestration) - # :doorhead, :doorsill, :doorjamb (opaque door) - # :skylighthead, :skylightsill, :skylightjamb (all other cases) - # - # ... if linked to: - # 1x subsurface (vertical or non-vertical) - edge[:surfaces].keys.each do |i| - break if is[:head ] - break if is[:sill ] - break if is[:jamb ] - break if is[:doorhead ] - break if is[:doorsill ] - break if is[:doorjamb ] - break if is[:skylighthead] - break if is[:skylightsill] - break if is[:skylightjamb] - next if deratables.include?(i) - next unless holes.key?(i) - - # In most cases, subsurface edges simply delineate the rough opening - # of its base surface (here, a "gardian"). Door sills, corner windows, - # as well as a subsurface header aligned with a plenum "floor" - # (ceiling tiles), are common instances where a subsurface edge links - # 2x (opaque) surfaces. Deratable surface "id" may not be the gardian - # of subsurface "i" - the latter may be a neighbour. The single - # "target" surface to derate is not the gardian in such cases. - gardian = deratables.size == 1 ? id : "" - target = gardian - - # Retrieve base surface's subsurfaces. - windows = surfaces[id].key?(:windows) - doors = surfaces[id].key?(:doors) - skylights = surfaces[id].key?(:skylights) - - windows = windows ? surfaces[id][:windows ] : {} - doors = doors ? surfaces[id][:doors ] : {} - skylights = skylights ? surfaces[id][:skylights] : {} - - # The gardian is "id" if subsurface "ids" holds "i". - ids = windows.keys + doors.keys + skylights.keys - - if gardian.empty? - other = deratables.first == id ? deratables.last : deratables.first - - gardian = ids.include?(i) ? id : other - target = ids.include?(i) ? other : id - - windows = surfaces[gardian].key?(:windows) - doors = surfaces[gardian].key?(:doors) - skylights = surfaces[gardian].key?(:skylights) - - windows = windows ? surfaces[gardian][:windows ] : {} - doors = doors ? surfaces[gardian][:doors ] : {} - skylights = skylights ? surfaces[gardian][:skylights] : {} - - ids = windows.keys + doors.keys + skylights.keys - end - - unless ids.include?(i) - log(ERR, "Orphaned subsurface #{i} (mth)") - next - end - - window = windows.key?(i) ? windows[i] : {} - door = doors.key?(i) ? doors[i] : {} - skylight = skylights.key?(i) ? skylights[i] : {} - - sub = window unless window.empty? - sub = door unless door.empty? - sub = skylight unless skylight.empty? - - window = sub[:type] == :window - door = sub[:type] == :door - glazed = door && sub.key?(:glazed) && sub[:glazed] - - s1 = edge[:surfaces][target] - s2 = edge[:surfaces][i ] - concave = TBD.concave?(s1, s2) - convex = TBD.convex?(s1, s2) - flat = !concave && !convex - - # Subsurface edges are tagged as head, sill or jamb, regardless of - # building PSI set subsurface-related tags. If the latter is simply - # :fenestration, then its single PSI factor is systematically - # assigned to e.g. a window's :head, :sill & :jamb edges. - # - # Additionally, concave or convex variants also inherit from the base - # type if undefined in the PSI set. - # - # If a subsurface is not horizontal, TBD tags any horizontal edge as - # either :head or :sill based on the polar angle of the subsurface - # around the edge vs sky zenith. Otherwise, all other subsurface edges - # are tagged as :jamb. - if ((s2[:normal].dot(zenith)).abs - 1).abs < TOL # horizontal surface - if glazed || window - set[:jamb ] = shorts[:val][:jamb ] if flat - set[:jambconcave] = shorts[:val][:jambconcave] if concave - set[:jambconvex ] = shorts[:val][:jambconvex ] if convex - is[:jamb ] = true - elsif door - set[:doorjamb ] = shorts[:val][:doorjamb ] if flat - set[:doorjambconcave] = shorts[:val][:doorjambconcave] if concave - set[:doorjambconvex ] = shorts[:val][:doorjambconvex ] if convex - is[:doorjamb ] = true - else - set[:skylightjamb ] = shorts[:val][:skylightjamb ] if flat - set[:skylightjambconcave] = shorts[:val][:skylightjambconcave] if concave - set[:skylightjambconvex ] = shorts[:val][:skylightjambconvex ] if convex - is[:skylightjamb ] = true - end - else - if glazed || window - if edge[:horizontal] - if s2[:polar].dot(zenith) < 0 - set[:head ] = shorts[:val][:head ] if flat - set[:headconcave] = shorts[:val][:headconcave] if concave - set[:headconvex ] = shorts[:val][:headconvex ] if convex - is[:head ] = true - else - set[:sill ] = shorts[:val][:sill ] if flat - set[:sillconcave] = shorts[:val][:sillconcave] if concave - set[:sillconvex ] = shorts[:val][:sillconvex ] if convex - is[:sill ] = true - end - else - set[:jamb ] = shorts[:val][:jamb ] if flat - set[:jambconcave] = shorts[:val][:jambconcave] if concave - set[:jambconvex ] = shorts[:val][:jambconvex ] if convex - is[:jamb ] = true - end - elsif door - if edge[:horizontal] - if s2[:polar].dot(zenith) < 0 - - set[:doorhead ] = shorts[:val][:doorhead ] if flat - set[:doorheadconcave] = shorts[:val][:doorheadconcave] if concave - set[:doorheadconvex ] = shorts[:val][:doorheadconvex ] if convex - is[:doorhead ] = true - else - set[:doorsill ] = shorts[:val][:doorsill ] if flat - set[:doorsillconcave] = shorts[:val][:doorsillconcave] if concave - set[:doorsillconvex ] = shorts[:val][:doorsillconvex ] if convex - is[:doorsill ] = true - end - else - set[:doorjamb ] = shorts[:val][:doorjamb ] if flat - set[:doorjambconcave] = shorts[:val][:doorjambconcave] if concave - set[:doorjambconvex ] = shorts[:val][:doorjambconvex ] if convex - is[:doorjamb ] = true - end - else - if edge[:horizontal] - if s2[:polar].dot(zenith) < 0 - set[:skylighthead ] = shorts[:val][:skylighthead ] if flat - set[:skylightheadconcave] = shorts[:val][:skylightheadconcave] if concave - set[:skylightheadconvex ] = shorts[:val][:skylightheadconvex ] if convex - is[:skylighthead ] = true - else - set[:skylightsill ] = shorts[:val][:skylightsill ] if flat - set[:skylightsillconcave] = shorts[:val][:skylightsillconcave] if concave - set[:skylightsillconvex ] = shorts[:val][:skylightsillconvex ] if convex - is[:skylightsill ] = true - end - else - set[:skylightjamb ] = shorts[:val][:skylightjamb ] if flat - set[:skylightjambconcave] = shorts[:val][:skylightjambconcave] if concave - set[:skylightjambconvex ] = shorts[:val][:skylightjambconvex ] if convex - is[:skylightjamb ] = true - end - end - end - end - - # Label edge as :spandrel if linked to: - # 1x deratable, non-spandrel wall - # 1x deratable, spandrel wall - edge[:surfaces].keys.each do |i| - break if is[:spandrel] - break unless deratables.size == 2 - break unless walls.key?(id) - break unless walls[id][:spandrel] - next if i == id - next unless deratables.include?(i) - next unless walls.key?(i) - next if walls[i][:spandrel] - - s1 = edge[:surfaces][id] - s2 = edge[:surfaces][i ] - concave = TBD.concave?(s1, s2) - convex = TBD.convex?(s1, s2) - flat = !concave && !convex - - set[:spandrel ] = shorts[:val][:spandrel ] if flat - set[:spandrelconcave] = shorts[:val][:spandrelconcave] if concave - set[:spandrelconvex ] = shorts[:val][:spandrelconvex ] if convex - is[:spandrel ] = true - end - - # Label edge as :cornerconcave or :cornerconvex if linked to: - # 2x deratable walls & f(relative polar wall vectors around edge) - edge[:surfaces].keys.each do |i| - break if is[:corner] - break unless deratables.size == 2 - break unless walls.key?(id) - next if i == id - next unless deratables.include?(i) - next unless walls.key?(i) - - s1 = edge[:surfaces][id] - s2 = edge[:surfaces][i ] - concave = TBD.concave?(s1, s2) - convex = TBD.convex?(s1, s2) - - set[:cornerconcave] = shorts[:val][:cornerconcave] if concave - set[:cornerconvex ] = shorts[:val][:cornerconvex ] if convex - is[:corner ] = true - end - - # Label edge as :parapet/:roof if linked to: - # 1x deratable wall - # 1x deratable ceiling - edge[:surfaces].keys.each do |i| - break if is[:parapet] - break if is[:roof ] - break unless deratables.size == 2 - break unless ceilings.key?(id) - next if i == id - next unless deratables.include?(i) - next unless walls.key?(i) - - s1 = edge[:surfaces][id] - s2 = edge[:surfaces][i ] - concave = TBD.concave?(s1, s2) - convex = TBD.convex?(s1, s2) - flat = !concave && !convex - - if argh[:parapet] - set[:parapet ] = shorts[:val][:parapet ] if flat - set[:parapetconcave] = shorts[:val][:parapetconcave] if concave - set[:parapetconvex ] = shorts[:val][:parapetconvex ] if convex - is[:parapet ] = true - else - set[:roof ] = shorts[:val][:roof ] if flat - set[:roofconcave] = shorts[:val][:roofconcave] if concave - set[:roofconvex ] = shorts[:val][:roofconvex ] if convex - is[:roof ] = true - end - end - - # Label edge as :party if linked to: - # 1x OtherSideCoefficients surface - # 1x (only) deratable surface - edge[:surfaces].keys.each do |i| - break if is[:party] - break unless deratables.size == 1 - next if i == id - next unless surfaces.key?(i) - next if holes.key?(i) - next if shades.key?(i) - - facing = surfaces[i][:boundary] - next unless facing == "othersidecoefficients" - - s1 = edge[:surfaces][id] - s2 = edge[:surfaces][i ] - concave = concave?(s1, s2) - convex = convex?(s1, s2) - flat = !concave && !convex - - set[:party ] = shorts[:val][:party ] if flat - set[:partyconcave] = shorts[:val][:partyconcave] if concave - set[:partyconvex ] = shorts[:val][:partyconvex ] if convex - is[:party ] = true - end - - # Label edge as :grade if linked to: - # 1x surface (e.g. slab or wall) facing ground - # 1x surface (i.e. wall) facing outdoors - edge[:surfaces].keys.each do |i| - break if is[:grade] - break unless deratables.size == 1 - next if i == id - next unless surfaces.key?(i) - next unless surfaces[i].key?(:ground) - next unless surfaces[i][:ground] - - s1 = edge[:surfaces][id] - s2 = edge[:surfaces][i] - concave = TBD.concave?(s1, s2) - convex = TBD.convex?(s1, s2) - flat = !concave && !convex - - set[:grade ] = shorts[:val][:grade ] if flat - set[:gradeconcave] = shorts[:val][:gradeconcave] if concave - set[:gradeconvex ] = shorts[:val][:gradeconvex ] if convex - is[:grade ] = true - end - - # Label edge as :rimjoist, :balcony or :balconysill if linked to: - # 1x deratable surface - # 1x CONDITIONED floor - # 1x shade (optional) - # 1x subsurface (optional) - balcony = false - balconysill = false - - edge[:surfaces].keys.each do |i| - break if balcony - next if i == id - - balcony = shades.key?(i) - end - - edge[:surfaces].keys.each do |i| - break unless balcony - break if balconysill - next if i == id - - balconysill = holes.key?(i) - end - - edge[:surfaces].keys.each do |i| - break if is[:rimjoist] || is[:balcony] || is[:balconysill] - break unless deratables.size == 2 - break if floors.key?(id) - next if i == id - next unless floors.key?(i) - next unless floors[i].key?(:conditioned) - next unless floors[i][:conditioned] - next if floors[i][:ground] - - other = deratables.first unless deratables.first == id - other = deratables.last unless deratables.last == id - other = id if deratables.size == 1 - - s1 = edge[:surfaces][id ] - s2 = edge[:surfaces][other] - concave = TBD.concave?(s1, s2) - convex = TBD.convex?(s1, s2) - flat = !concave && !convex - - if balconysill - set[:balconysill ] = shorts[:val][:balconysill ] if flat - set[:balconysillconcave] = shorts[:val][:balconysillconcave] if concave - set[:balconysillconvex ] = shorts[:val][:balconysillconvex ] if convex - is[:balconysill ] = true - elsif balcony - set[:balcony ] = shorts[:val][:balcony ] if flat - set[:balconyconcave ] = shorts[:val][:balconyconcave ] if concave - set[:balconyconvex ] = shorts[:val][:balconyconvex ] if convex - is[:balcony ] = true - else - set[:rimjoist ] = shorts[:val][:rimjoist ] if flat - set[:rimjoistconcave] = shorts[:val][:rimjoistconcave] if concave - set[:rimjoistconvex ] = shorts[:val][:rimjoistconvex ] if convex - is[:rimjoist ] = true - end - end # edge's surfaces loop - end - - edge[:psi] = set unless set.empty? - edge[:set] = psi unless set.empty? - end # edge loop - - # Tracking (mild) transitions. - transitions = {} - - edges.each do |tag, edge| - trnz = [] - deratable = false - next if edge.key?(:psi) - next unless edge.key?(:surfaces) - - edge[:surfaces].keys.each do |id| - next unless surfaces.key?(id) - next unless surfaces[id][:deratable] - - deratable = surfaces[id][:deratable] - trnz << id - end - - next unless deratable - - edge[:psi] = { transition: 0.000 } - edge[:set] = json[:io][:building][:psi] - - transitions[tag] = trnz unless trnz.empty? - end - - # Lo Scrigno: such transitions occur between plenum floor plates. - expect(transitions).to_not be_empty - expect(transitions.size).to eq(10) - # transitions.values.each { |trr| puts "#{trr}\n" } - # ["p_E_floor" , "p_floor" ] * - # ["p_W2_floor", "p_W1_floor"] + - # ["p_W4_floor", "p_W2_floor"] $ - # ["p_floor" , "p_W2_floor"] * - # ["p_floor" , "p_W4_floor"] * - # ["p_floor" , "p_W3_floor"] * - # ["p_W3_floor", "p_W4_floor"] $ - # ["p_W3_floor", "p_W1_floor"] + - # ["g_S2_wall" , "g_S1_wall" ] ! - # ["g_S3_wall" , "g_S2_wall" ] ! - w1_count = 0 - - transitions.values.each do |trnz| - expect(trnz.size).to eq(2) - - if trnz.include?("g_S2_wall") # ! - expect(trnz).to include("g_S1_wall").or include("g_S3_wall") - elsif trnz.include?("p_W1_floor") # + - w1_count += 1 - expect(trnz).to include("p_W2_floor").or include("p_W3_floor") - elsif trnz.include?("p_floor") # * - expect(trnz).to_not include("p_W1_floor") - else # $ - expect(trnz).to include("p_W4_floor") - end - end - - expect(w1_count).to eq(2) - - # At this stage, edges may have been tagged multiple times (e.g. :sill as - # well as :balconysill); TBD has yet to make final edge type determinations. - n_derating_edges = 0 - n_edges_at_grade = 0 - n_edges_as_balconies = 0 - n_edges_as_balconysills = 0 - n_edges_as_parapets = 0 - n_edges_as_rimjoists = 0 - n_edges_as_concave_rimjoists = 0 - n_edges_as_convex_rimjoists = 0 - n_edges_as_fenestrations = 0 - n_edges_as_heads = 0 - n_edges_as_sills = 0 - n_edges_as_jambs = 0 - n_edges_as_concave_jambs = 0 - n_edges_as_convex_jambs = 0 - n_edges_as_doorheads = 0 - n_edges_as_doorsills = 0 - n_edges_as_doorjambs = 0 - n_edges_as_doorconcave_jambs = 0 - n_edges_as_doorconvex_jambs = 0 - n_edges_as_skylightheads = 0 - n_edges_as_skylightsills = 0 - n_edges_as_skylightjambs = 0 - n_edges_as_skylightconcave_jambs = 0 - n_edges_as_skylightconvex_jambs = 0 - n_edges_as_corners = 0 - n_edges_as_concave_corners = 0 - n_edges_as_convex_corners = 0 - n_edges_as_transitions = 0 - - edges.values.each do |edge| - next unless edge.key?(:psi) - - n_derating_edges += 1 - n_edges_at_grade += 1 if edge[:psi].key?(:grade) - n_edges_at_grade += 1 if edge[:psi].key?(:gradeconcave) - n_edges_at_grade += 1 if edge[:psi].key?(:gradeconvex) - n_edges_as_balconies += 1 if edge[:psi].key?(:balcony) - n_edges_as_balconysills += 1 if edge[:psi].key?(:balconysill) - n_edges_as_parapets += 1 if edge[:psi].key?(:parapetconcave) - n_edges_as_parapets += 1 if edge[:psi].key?(:parapetconvex) - n_edges_as_rimjoists += 1 if edge[:psi].key?(:rimjoist) - n_edges_as_concave_rimjoists += 1 if edge[:psi].key?(:rimjoistconcave) - n_edges_as_convex_rimjoists += 1 if edge[:psi].key?(:rimjoistconvex) - n_edges_as_fenestrations += 1 if edge[:psi].key?(:fenestration) - n_edges_as_heads += 1 if edge[:psi].key?(:head) - n_edges_as_sills += 1 if edge[:psi].key?(:sill) - n_edges_as_jambs += 1 if edge[:psi].key?(:jamb) - n_edges_as_concave_jambs += 1 if edge[:psi].key?(:jambconcave) - n_edges_as_convex_jambs += 1 if edge[:psi].key?(:jambconvex) - n_edges_as_doorheads += 1 if edge[:psi].key?(:doorhead) - n_edges_as_doorsills += 1 if edge[:psi].key?(:doorsill) - n_edges_as_doorjambs += 1 if edge[:psi].key?(:doorjamb) - n_edges_as_doorconcave_jambs += 1 if edge[:psi].key?(:doorjambconcave) - n_edges_as_doorconvex_jambs += 1 if edge[:psi].key?(:doorjambconvex) - n_edges_as_skylightheads += 1 if edge[:psi].key?(:skylighthead) - n_edges_as_skylightsills += 1 if edge[:psi].key?(:skylightsill) - n_edges_as_skylightjambs += 1 if edge[:psi].key?(:skylightjamb) - n_edges_as_skylightconcave_jambs += 1 if edge[:psi].key?(:skylightjambconcave) - n_edges_as_skylightconvex_jambs += 1 if edge[:psi].key?(:skylightjambconvex) - n_edges_as_corners += 1 if edge[:psi].key?(:corner) - n_edges_as_concave_corners += 1 if edge[:psi].key?(:cornerconcave) - n_edges_as_convex_corners += 1 if edge[:psi].key?(:cornerconvex) - n_edges_as_transitions += 1 if edge[:psi].key?(:transition) - end - - expect(n_derating_edges ).to eq(77) - expect(n_edges_at_grade ).to eq( 0) - expect(n_edges_as_balconies ).to eq( 2) # not balconysills - expect(n_edges_as_balconysills ).to eq( 2) # == sills - expect(n_edges_as_parapets ).to eq(12) # 5x around rooftop strip - expect(n_edges_as_rimjoists ).to eq( 5) - expect(n_edges_as_concave_rimjoists ).to eq( 5) - expect(n_edges_as_convex_rimjoists ).to eq(18) - expect(n_edges_as_fenestrations ).to eq( 0) - expect(n_edges_as_heads ).to eq( 2) # "vertical fenestration" - expect(n_edges_as_sills ).to eq( 2) # == balcony sills - expect(n_edges_as_jambs ).to eq( 4) - expect(n_edges_as_concave_jambs ).to eq( 0) - expect(n_edges_as_convex_jambs ).to eq( 0) - expect(n_edges_as_doorheads ).to eq( 0) # "vertical fenestration" - expect(n_edges_as_doorsills ).to eq( 0) # "vertical fenestration" - expect(n_edges_as_doorjambs ).to eq( 0) # "vertical fenestration" - expect(n_edges_as_doorconcave_jambs ).to eq( 0) # "vertical fenestration" - expect(n_edges_as_doorconvex_jambs ).to eq( 0) # "vertical fenestration" - expect(n_edges_as_skylightheads ).to eq( 0) - expect(n_edges_as_skylightsills ).to eq( 0) - expect(n_edges_as_skylightjambs ).to eq( 1) # along 1" rooftop strip - expect(n_edges_as_skylightconcave_jambs).to eq( 0) - expect(n_edges_as_skylightconvex_jambs ).to eq( 3) # 3x parapet edges - expect(n_edges_as_corners ).to eq( 0) - expect(n_edges_as_concave_corners ).to eq( 4) - expect(n_edges_as_convex_corners ).to eq(12) - expect(n_edges_as_transitions ).to eq(10) - - # Loop through each edge and assign heat loss to linked surfaces. - edges.each do |identifier, edge| - next unless edge.key?(:psi) - - rsi = 0 - max = edge[:psi].values.max - type = edge[:psi].key(max) - length = edge[:length] - bridge = { psi: max, type: type, length: length } - deratables = {} - apertures = {} - - if edge.key?(:sets) && edge[:sets].key?(type) - edge[:set] = edge[:sets][type] - end - - # Retrieve valid linked surfaces as deratables. - edge[:surfaces].each do |id, s| - next unless surfaces.key?(id) - - deratables[id] = s if surfaces[id][:deratable] - end - - edge[:surfaces].each { |id, s| apertures[id] = s if holes.key?(id) } - next if apertures.size > 1 # edge links 2x openings - - # Prune dad if edge links an opening, its dad and an uncle. - if deratables.size > 1 && apertures.size > 0 - deratables.each do |id, deratable| - [:windows, :doors, :skylights].each do |types| - next unless surfaces[id].key?(types) - - surfaces[id][types].keys.each do |sub| - deratables.delete(id) if apertures.key?(sub) - end - end - end - end - - next if deratables.empty? - - # Sum RSI of targeted insulating layer from each deratable surface. - deratables.each do |id, deratable| - expect(surfaces[id]).to have_key(:r) - rsi += surfaces[id][:r] - end - - # Assign heat loss from thermal bridges to surfaces, in proportion to - # insulating layer thermal resistance - deratables.each do |id, deratable| - ratio = 0 - ratio = surfaces[id][:r] / rsi if rsi > 0.001 - loss = bridge[:psi] * ratio - b = { psi: loss, type: bridge[:type], length: length, ratio: ratio } - surfaces[id][:edges] = {} unless surfaces[id].key?(:edges) - surfaces[id][:edges][identifier] = b - end - end - - # Assign thermal bridging heat loss [in W/K] to each deratable surface. - n_surfaces_to_derate = 0 - - surfaces.each do |id, surface| - next unless surface.key?(:edges) - - n_surfaces_to_derate += 1 - surface[:heatloss] = 0 - e = surface[:edges].values - - e.each { |edge| surface[:heatloss] += edge[:psi] * edge[:length] } - end - - expect(n_surfaces_to_derate).to eq(27) # if "poor (BETBG)" - - ["e_p_wall", "g_floor", "p_top", "p_e_wall"].each do |id| - expect(surfaces[id]).to_not have_key(:heatloss) - end - - # If "poor (BETBG)". - expect(surfaces["e_E_wall" ][:heatloss]).to be_within(TOL).of( 6.02) - expect(surfaces["e_N_wall" ][:heatloss]).to be_within(TOL).of( 4.73) - expect(surfaces["e_S_wall" ][:heatloss]).to be_within(TOL).of( 7.70) - expect(surfaces["e_W_wall" ][:heatloss]).to be_within(TOL).of( 6.02) - expect(surfaces["e_floor" ][:heatloss]).to be_within(TOL).of( 8.01) - expect(surfaces["e_top" ][:heatloss]).to be_within(TOL).of( 4.40) - expect(surfaces["g_E_wall" ][:heatloss]).to be_within(TOL).of(18.19) - expect(surfaces["g_N_wall" ][:heatloss]).to be_within(TOL).of(54.25) - expect(surfaces["g_S1_wall" ][:heatloss]).to be_within(TOL).of( 9.43) - expect(surfaces["g_S2_wall" ][:heatloss]).to be_within(TOL).of( 3.20) - expect(surfaces["g_S3_wall" ][:heatloss]).to be_within(TOL).of(28.88) - expect(surfaces["g_W_wall" ][:heatloss]).to be_within(TOL).of(18.19) - expect(surfaces["g_top" ][:heatloss]).to be_within(TOL).of(32.96) - expect(surfaces["p_E_floor" ][:heatloss]).to be_within(TOL).of(18.65) - expect(surfaces["p_N_wall" ][:heatloss]).to be_within(TOL).of(37.25) - expect(surfaces["p_S1_wall" ][:heatloss]).to be_within(TOL).of( 7.06) - expect(surfaces["p_S2_wall" ][:heatloss]).to be_within(TOL).of(27.27) - expect(surfaces["p_W1_floor"][:heatloss]).to be_within(TOL).of(13.77) - expect(surfaces["p_W2_floor"][:heatloss]).to be_within(TOL).of( 5.92) - expect(surfaces["p_W3_floor"][:heatloss]).to be_within(TOL).of( 5.92) - expect(surfaces["p_W4_floor"][:heatloss]).to be_within(TOL).of( 1.90) - expect(surfaces["p_floor" ][:heatloss]).to be_within(TOL).of(10.00) - expect(surfaces["s_E_wall" ][:heatloss]).to be_within(TOL).of( 5.04) - expect(surfaces["s_N_wall" ][:heatloss]).to be_within(TOL).of( 6.58) - expect(surfaces["s_S_wall" ][:heatloss]).to be_within(TOL).of( 6.58) - expect(surfaces["s_W_wall" ][:heatloss]).to be_within(TOL).of( 5.68) - expect(surfaces["s_floor" ][:heatloss]).to be_within(TOL).of( 8.80) - - surfaces.each do |id, surface| - next unless surface.key?(:construction) - next unless surface.key?(:index) - next unless surface.key?(:ltype) - next unless surface.key?(:r) - next unless surface.key?(:edges) - next unless surface.key?(:heatloss) - next unless surface[:heatloss].abs > TOL - - s = model.getSurfaceByName(id) - next if s.empty? - - s = s.get - - index = surface[:index ] - current_c = surface[:construction] - c = current_c.clone(model).to_LayeredConstruction.get - m = nil - m = TBD.derate(id, surface, c) if index - - if m - c.setLayer(index, m) - c.setName("#{id} c tbd") - s.setConstruction(c) - - if s.outsideBoundaryCondition.downcase == "surface" - unless s.adjacentSurface.empty? - adjacent = s.adjacentSurface.get - nom = adjacent.nameString - default = adjacent.isConstructionDefaulted == false - - if default && surfaces.key?(nom) - current_cc = surfaces[nom][:construction] - cc = current_cc.clone(model).to_LayeredConstruction.get - cc.setLayer(surfaces[nom][:index], m) - cc.setName("#{nom} c tbd") - adjacent.setConstruction(cc) - end - end - end - end - end - - floors.each do |id, floor| - next unless floor.key?(:edges) - - s = model.getSurfaceByName(id) - expect(s).to_not be_empty - expect(s.get.isConstructionDefaulted).to be false - expect(s.get.construction.get.nameString).to include(" tbd") - end - - ceilings.each do |id, ceiling| - next unless ceiling.key?(:edges) - - s = model.getSurfaceByName(id) - expect(s).to_not be_empty - expect(s.get.isConstructionDefaulted).to be false - expect(s.get.construction.get.nameString).to include(" tbd") - end - - walls.each do |id, wall| - next unless wall.key?(:edges) - - s = model.getSurfaceByName(id) - expect(s).to_not be_empty - expect(s.get.isConstructionDefaulted).to be false - expect(s.get.construction.get.nameString).to include(" tbd") - end - end - - it "can check for balcony sills (ASHRAE 90.1 2022)" do - translator = OpenStudio::OSVersion::VersionTranslator.new - TBD.clean! # "Lo Scrigno" (or Jewel Box), by Renzo Piano (Lingotto Factory, Turin); a # cantilevered, single space art gallery (space #1) above a supply plenum # with slanted undersides (space #2) resting on four main pillars. - file = File.join(__dir__, "files/osms/out/loscrigno.osm") + file = File.join(__dir__, "files/osms/in/loscrigno.osm") path = OpenStudio::Path.new(file) model = translator.loadModel(path) expect(model).to_not be_empty @@ -2084,7 +242,7 @@ translator = OpenStudio::OSVersion::VersionTranslator.new TBD.clean! - file = File.join(__dir__, "files/osms/out/loscrigno.osm") + file = File.join(__dir__, "files/osms/in/loscrigno.osm") path = OpenStudio::Path.new(file) model = translator.loadModel(path) expect(model).to_not be_empty @@ -2219,7 +377,7 @@ expect(n_edges_as_transitions ).to eq(10) # Re-do, without changing door surface types. - file = File.join(__dir__, "files/osms/out/loscrigno.osm") + file = File.join(__dir__, "files/osms/in/loscrigno.osm") path = OpenStudio::Path.new(file) model = translator.loadModel(path) expect(model).to_not be_empty From b435624ccdbf8eb7429029d3da3e7942f5d16ea5 Mon Sep 17 00:00:00 2001 From: brgix Date: Mon, 13 Apr 2026 06:37:43 -0400 Subject: [PATCH 06/10] Harmonizes test sequence with TBD repo --- spec/tbd_tests_spec.rb | 17610 +++++++++++++++++++-------------------- 1 file changed, 8802 insertions(+), 8808 deletions(-) diff --git a/spec/tbd_tests_spec.rb b/spec/tbd_tests_spec.rb index 076ba40..236d30d 100644 --- a/spec/tbd_tests_spec.rb +++ b/spec/tbd_tests_spec.rb @@ -17,24 +17,148 @@ RMIN = TBD::RMIN.dup RMAX = TBD::RMAX.dup - it "can check for balcony sills (ASHRAE 90.1 2022)" do + it "can process JSON surface KHI entries" do translator = OpenStudio::OSVersion::VersionTranslator.new expect(TBD.level ).to eq(INF) expect(TBD.reset(DBG)).to eq(DBG) expect(TBD.level ).to eq(DBG) expect(TBD.clean! ).to eq(DBG) - # "Lo Scrigno" (or Jewel Box), by Renzo Piano (Lingotto Factory, Turin); a - # cantilevered, single space art gallery (space #1) above a supply plenum - # with slanted undersides (space #2) resting on four main pillars. - file = File.join(__dir__, "files/osms/in/loscrigno.osm") + # First, basic IO tests with invalid entries. + k = TBD::KHI.new + expect(k.point).to be_a(Hash) + expect(k.point.size).to eq(14) + + # Invalid identifier key. + new_KHI = { name: "new_KHI", point: 1.0 } + expect(k.append(new_KHI)).to be false + expect(TBD.debug?).to be true + expect(TBD.logs.size).to eq(1) + expect(TBD.logs.first[:message]).to include("Missing 'id' key") + TBD.clean! + + # Invalid identifier. + new_KHI = { id: nil, point: 1.0 } + expect(k.append(new_KHI)).to be false + expect(TBD.error?).to be true + expect(TBD.logs.size).to eq(1) + expect(TBD.logs.first[:message]).to include("'KHI id' NilClass?") + TBD.clean! + + # Odd (yet valid) identifier. + new_KHI = { id: [], point: 1.0 } + expect(k.append(new_KHI)).to be true + expect(TBD.status).to be_zero + expect(k.point.keys).to include("[]") + expect(k.point.size).to eq(15) + + # Existing identifier. + new_KHI = { id: "code (Quebec)", point: 1.0 } + expect(k.append(new_KHI)).to be false + expect(TBD.error?).to be true + expect(TBD.logs.size).to eq(1) + expect(TBD.logs.first[:message]).to include("existing KHI entry") + TBD.clean! + + # Missing point conductance. + new_KHI = { id: "foo" } + expect(k.append(new_KHI)).to be false + expect(TBD.debug?).to be true + expect(TBD.logs.size).to eq(1) + expect(TBD.logs.first[:message]).to include("Missing 'point' key") + + # Valid JSON entries. + TBD.clean! + version = OpenStudio.openStudioVersion.split(".").join.to_i + + # The v1.11.5 (2016) seb.osm, shipped with OpenStudio, holds (what would now + # be considered as deprecated) a definition of plenum floors (i.e. ceiling + # tiles) generating several warnings with more recent OpenStudio versions. + file = File.join(__dir__, "files/osms/in/seb.osm") path = OpenStudio::Path.new(file) model = translator.loadModel(path) expect(model).to_not be_empty model = model.get - argh = { option: "90.1.22|steel.m|default" } - json = TBD.process(model, argh) + # "Shading Surface 4" is overlapping with a plenum exterior wall - delete. + sh4 = model.getShadingSurfaceByName("Shading Surface 4") + expect(sh4).to_not be_empty + sh4 = sh4.get + sh4.remove + + plenum = model.getSpaceByName("Level 0 Ceiling Plenum") + expect(plenum).to_not be_empty + plenum = plenum.get + + thzone = plenum.thermalZone + expect(thzone).to_not be_empty + thzone = thzone.get + + # Before the fix. + unless version < 350 + expect(plenum.isEnclosedVolume).to be true + expect(plenum.isVolumeDefaulted).to be true + expect(plenum.isVolumeAutocalculated).to be true + end + + if version > 350 && version < 370 + expect(plenum.volume.round(0)).to eq(234) + else + expect(plenum.volume.round(0)).to eq(0) + end + + expect(thzone.isVolumeDefaulted).to be true + expect(thzone.isVolumeAutocalculated).to be true + expect(thzone.volume).to be_empty + + plenum.surfaces.each do |s| + next if s.outsideBoundaryCondition.downcase == "outdoors" + + # If a SEB plenum surface isn't facing outdoors, it's 1 of 4 "floor" + # surfaces (each facing a ceiling surface below). + adj = s.adjacentSurface + expect(adj).to_not be_empty + adj = adj.get + expect(adj.vertices.size).to eq(s.vertices.size) + + # Same vertex sequence? Should be in reverse order. + adj.vertices.each_with_index do |vertex, i| + expect(TBD.same?(vertex, s.vertices.at(i))).to be true + end + + expect(adj.surfaceType).to eq("RoofCeiling") + expect(s.surfaceType).to eq("RoofCeiling") + expect(s.setSurfaceType("Floor")).to be true + expect(s.setVertices(s.vertices.reverse)).to be true + + # Vertices now in reverse order. + adj.vertices.reverse.each_with_index do |vertex, i| + expect(TBD.same?(vertex, s.vertices.at(i))).to be true + end + end + + # After the fix. + unless version < 350 + expect(plenum.isEnclosedVolume).to be true + expect(plenum.isVolumeDefaulted).to be true + expect(plenum.isVolumeAutocalculated).to be true + end + + expect(plenum.volume.round(0)).to eq(50) # right answer + expect(thzone.isVolumeDefaulted).to be true + expect(thzone.isVolumeAutocalculated).to be true + expect(thzone.volume).to be_empty + + 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") + argh[:schema_path] = File.join(__dir__, "../tbd.schema.json") + + json = TBD.process(model, argh) expect(json).to be_a(Hash) expect(json).to have_key(:io) expect(json).to have_key(:surfaces) @@ -42,232 +166,157 @@ surfaces = json[:surfaces] expect(TBD.status).to be_zero expect(TBD.logs).to be_empty - expect(surfaces).to be_a Hash - expect(surfaces.size).to eq(31) + expect(surfaces).to be_a(Hash) + expect(surfaces.size).to eq(56) expect(io).to be_a(Hash) expect(io).to have_key(:edges) - expect(io[:edges].size).to eq(77) - - n_edges_at_grade = 0 - n_edges_as_balconies = 0 - n_edges_as_balconysills = 0 - n_edges_as_balconydoorsills = 0 - n_edges_as_concave_parapets = 0 - n_edges_as_convex_parapets = 0 - n_edges_as_concave_roofs = 0 - n_edges_as_convex_roofs = 0 - n_edges_as_rimjoists = 0 - n_edges_as_concave_rimjoists = 0 - n_edges_as_convex_rimjoists = 0 - n_edges_as_fenestrations = 0 - n_edges_as_heads = 0 - n_edges_as_sills = 0 - n_edges_as_jambs = 0 - n_edges_as_doorheads = 0 - n_edges_as_doorsills = 0 - n_edges_as_doorjambs = 0 - n_edges_as_skylightjambs = 0 - n_edges_as_concave_jambs = 0 - n_edges_as_convex_jambs = 0 - n_edges_as_corners = 0 - n_edges_as_concave_corners = 0 - n_edges_as_convex_corners = 0 - n_edges_as_transitions = 0 + expect(io[:edges].size).to eq(106) - io[:edges].each do |edge| - expect(edge).to have_key(:type) + # As the :building PSI set on file remains "(non thermal bridging)", one + # should not expect differences in results, i.e. derating shouldn't occur. + # However, the JSON file holds KHI entries for "Entryway Wall 2" : + # 3x "columns" @0.5 W/K + 4x supports @0.5W/K = 3.5 W/K + surfaces.values.each do |surface| + next unless surface.key?(:ratio) - n_edges_at_grade += 1 if edge[:type] == :grade - n_edges_at_grade += 1 if edge[:type] == :gradeconcave - n_edges_at_grade += 1 if edge[:type] == :gradeconvex - n_edges_as_balconies += 1 if edge[:type] == :balcony - n_edges_as_balconies += 1 if edge[:type] == :balconyconcave - n_edges_as_balconies += 1 if edge[:type] == :balconyconvex - n_edges_as_balconysills += 1 if edge[:type] == :balconysill - n_edges_as_balconysills += 1 if edge[:type] == :balconysillconcave - n_edges_as_balconysills += 1 if edge[:type] == :balconysillconvex - n_edges_as_balconydoorsills += 1 if edge[:type] == :balconydoorsill - n_edges_as_balconydoorsills += 1 if edge[:type] == :balconydoorsillconcave - n_edges_as_balconydoorsills += 1 if edge[:type] == :balconydoorsillconvex - n_edges_as_concave_parapets += 1 if edge[:type] == :parapetconcave - n_edges_as_convex_parapets += 1 if edge[:type] == :parapetconvex - n_edges_as_concave_roofs += 1 if edge[:type] == :roofconcave - n_edges_as_convex_roofs += 1 if edge[:type] == :roofconvex - n_edges_as_rimjoists += 1 if edge[:type] == :rimjoist - n_edges_as_concave_rimjoists += 1 if edge[:type] == :rimjoistconcave - n_edges_as_convex_rimjoists += 1 if edge[:type] == :rimjoistconvex - n_edges_as_fenestrations += 1 if edge[:type] == :fenestration - n_edges_as_heads += 1 if edge[:type] == :head - n_edges_as_heads += 1 if edge[:type] == :headconcave - n_edges_as_heads += 1 if edge[:type] == :headconvex - n_edges_as_sills += 1 if edge[:type] == :sill - n_edges_as_sills += 1 if edge[:type] == :sillconcave - n_edges_as_sills += 1 if edge[:type] == :sillconvex - n_edges_as_jambs += 1 if edge[:type] == :jamb - n_edges_as_concave_jambs += 1 if edge[:type] == :jambconcave - n_edges_as_convex_jambs += 1 if edge[:type] == :jambconvex - n_edges_as_doorheads += 1 if edge[:type] == :doorhead - n_edges_as_doorsills += 1 if edge[:type] == :doorsill - n_edges_as_doorjambs += 1 if edge[:type] == :doorjamb - n_edges_as_skylightjambs += 1 if edge[:type] == :skylightjamb - n_edges_as_skylightjambs += 1 if edge[:type] == :skylightjambconvex - n_edges_as_corners += 1 if edge[:type] == :corner - n_edges_as_concave_corners += 1 if edge[:type] == :cornerconcave - n_edges_as_convex_corners += 1 if edge[:type] == :cornerconvex - n_edges_as_transitions += 1 if edge[:type] == :transition + expect(surface[:heatloss]).to be_within(TOL).of(3.5) end - # Lo Scrigno holds 8x wall/roof edges: - # - 4x along gallery roof/skylight (all convex) - # - 4x along the elevator roof (3x convex + 1x concave) - # - # The gallery wall/roof edges are not modelled here "as built", but rather - # closer to details of another Renzo Piano extension: the Modern Wing of the - # Art Institute of Chicago. Both galleries are similar in that daylighting - # is zenithal, covering all (or nearly all) of the roof surface. In the - # case of Chicago, the roof is ~entirely glazed (as reflected in the model). - # - # www.archdaily.com/24652/the-modern-wing-renzo-piano/ - # 5010473228ba0d42220015f8-the-modern-wing-renzo-piano-image?next_project=no - # - # However, a small 1" strip is maintained along the South roof/wall edge of - # the gallery to ensure skylight area < roof area. - # - # No judgement here on the suitability of the design for either Chicago or - # 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.: - # - # - 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 - # - # 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. - # - # 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. - 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) - expect(n_edges_as_balconydoorsills ).to eq( 0) - expect(n_edges_as_concave_parapets ).to eq( 1) - expect(n_edges_as_convex_parapets ).to eq(11) - expect(n_edges_as_concave_roofs ).to eq( 0) - expect(n_edges_as_convex_roofs ).to eq( 0) - expect(n_edges_as_rimjoists ).to eq( 5) - expect(n_edges_as_concave_rimjoists).to eq( 5) - expect(n_edges_as_convex_rimjoists ).to eq(18) - expect(n_edges_as_fenestrations ).to eq( 0) - expect(n_edges_as_heads ).to eq( 2) # GlassDoor == fenestration - expect(n_edges_as_sills ).to eq( 0) # (2x balconysills) - expect(n_edges_as_jambs ).to eq( 4) - expect(n_edges_as_concave_jambs ).to eq( 0) - expect(n_edges_as_convex_jambs ).to eq( 0) - expect(n_edges_as_doorheads ).to eq( 0) - expect(n_edges_as_doorjambs ).to eq( 0) - expect(n_edges_as_doorsills ).to eq( 0) - expect(n_edges_as_skylightjambs ).to eq( 1) # along 1" rooftop strip - expect(n_edges_as_corners ).to eq( 0) - expect(n_edges_as_concave_corners ).to eq( 4) - expect(n_edges_as_convex_corners ).to eq(12) - expect(n_edges_as_transitions ).to eq(10) - - # For the purposes of the RSpec, vertical access (elevator and stairs, - # normally fully glazed) are modelled as (opaque) extensions of either - # space. Deratable (exterior) surfaces are grouped, prefixed as follows: - # - # - "g_" : art gallery - # - "p_" : underfloor plenum (supplying gallery) - # - "s_" : stairwell (leading to/through plenum & gallery) - # - "e_" : (side) elevator leading to gallery - # - # East vs West walls have equal heat loss (W/K) from major thermal bridging - # as they are symmetrical. North vs South walls differ slightly due to: - # - adjacency with elevator walls - # - different balcony lengths - expect(surfaces["g_E_wall" ][:heatloss]).to be_within(TOL).of( 4.30) - expect(surfaces["g_W_wall" ][:heatloss]).to be_within(TOL).of( 4.30) - expect(surfaces["g_N_wall" ][:heatloss]).to be_within(TOL).of(15.95) - expect(surfaces["g_S1_wall" ][:heatloss]).to be_within(TOL).of( 1.87) - expect(surfaces["g_S2_wall" ][:heatloss]).to be_within(TOL).of( 1.04) - expect(surfaces["g_S3_wall" ][:heatloss]).to be_within(TOL).of( 8.19) - - expect(surfaces["e_top" ][:heatloss]).to be_within(TOL).of( 1.43) - expect(surfaces["e_E_wall" ][:heatloss]).to be_within(TOL).of( 0.32) - expect(surfaces["e_W_wall" ][:heatloss]).to be_within(TOL).of( 0.32) - expect(surfaces["e_N_wall" ][:heatloss]).to be_within(TOL).of( 0.95) - expect(surfaces["e_S_wall" ][:heatloss]).to be_within(TOL).of( 0.85) - expect(surfaces["e_floor" ][:heatloss]).to be_within(TOL).of( 2.46) + # Retrieve :parapet edges along the "Open Area" plenum. + open = model.getSpaceByName("Open area 1") + expect(open).to_not be_empty + open = open.get - expect(surfaces["s_E_wall" ][:heatloss]).to be_within(TOL).of( 1.17) - expect(surfaces["s_W_wall" ][:heatloss]).to be_within(TOL).of( 1.17) - expect(surfaces["s_N_wall" ][:heatloss]).to be_within(TOL).of( 1.54) - expect(surfaces["s_S_wall" ][:heatloss]).to be_within(TOL).of( 1.54) - expect(surfaces["s_floor" ][:heatloss]).to be_within(TOL).of( 2.70) + open_roofs = TBD.roofs(open) + expect(open_roofs.size).to eq(1) + open_roof = open_roofs.first + roof_id = open_roof.nameString + expect(roof_id).to eq("Level 0 Open area 1 Ceiling Plenum RoofCeiling") - expect(surfaces["p_W1_floor"][:heatloss]).to be_within(TOL).of( 4.23) - expect(surfaces["p_W2_floor"][:heatloss]).to be_within(TOL).of( 1.82) - expect(surfaces["p_W3_floor"][:heatloss]).to be_within(TOL).of( 1.82) - expect(surfaces["p_W4_floor"][:heatloss]).to be_within(TOL).of( 0.58) - expect(surfaces["p_E_floor" ][:heatloss]).to be_within(TOL).of( 5.73) - expect(surfaces["p_N_wall" ][:heatloss]).to be_within(TOL).of(11.44) - expect(surfaces["p_S2_wall" ][:heatloss]).to be_within(TOL).of( 8.16) - expect(surfaces["p_S1_wall" ][:heatloss]).to be_within(TOL).of( 2.04) - expect(surfaces["p_floor" ][:heatloss]).to be_within(TOL).of( 3.07) + # There are only 2 types of edges along the "Open Area" plenum roof: + # 1. (5x) convex :parapet edges, and + # 2. (5x) transition edges (shared with neighbouring flat roof surfaces). + roof_edges = io[:edges].select { |edg| edg[:surfaces].include?(roof_id) } + parapets = roof_edges.select { |edg| edg[:type] == :parapetconvex } + transitions = roof_edges.select { |edg| edg[:type] == :transition } + expect(parapets.size).to eq(5) + expect(transitions.size).to eq(5) + expect(roof_edges.size).to eq(parapets.size + transitions.size) - expect(argh).to have_key(:io) - out = JSON.pretty_generate(argh[:io]) - outP = File.join(__dir__, "../json/tbd_loscrigno1.out.json") - File.open(outP, "w") { |outP| outP.puts out } + roof_edges.each { |edg| expect(edg[:surfaces].size).to eq(2) } end - it "can switch between parapet/roof edge types" do + it "can process JSON surface KHI & PSI entries + building & edge" do translator = OpenStudio::OSVersion::VersionTranslator.new TBD.clean! - file = File.join(__dir__, "files/osms/in/loscrigno.osm") + # First, basic IO tests with invalid entries. + ps = TBD::PSI.new + expect(ps.set).to be_a(Hash) + expect(ps.has).to be_a(Hash) + expect(ps.val).to be_a(Hash) + expect(ps.set.size).to eq(16) + expect(ps.has.size).to eq(16) + expect(ps.val.size).to eq(16) + + expect(ps.gen(nil)).to be false + expect(TBD.status).to be_zero + + # Invalid identifier key. + new_PSI = { name: "new_PSI", balcony: 1.0 } + expect(ps.append(new_PSI)).to be false + expect(TBD.debug?).to be true + expect(TBD.logs.size).to eq(1) + expect(TBD.logs.first[:message]).to include("Missing 'id' key") + TBD.clean! + + # Invalid identifier. + new_PSI = { id: nil, balcony: 1.0 } + expect(ps.append(new_PSI)).to be false + expect(TBD.error?).to be true + expect(TBD.logs.size).to eq(1) + expect(TBD.logs.first[:message]).to include("'set ID' NilClass?") + TBD.clean! + + # Odd (yet valid) identifier. + new_PSI = { id: [], balcony: 1.0 } + expect(ps.append(new_PSI)).to be true + expect(TBD.status).to be_zero + expect(ps.set.keys).to include("[]") + expect(ps.has.keys).to include("[]") + expect(ps.val.keys).to include("[]") + expect(ps.set.size).to eq(17) + expect(ps.has.size).to eq(17) + expect(ps.val.size).to eq(17) + + # Existing identifier. + new_PSI = { id: "code (Quebec)", balcony: 1.0 } + expect(ps.append(new_PSI)).to be false + expect(TBD.error?).to be true + expect(TBD.logs.size).to eq(1) + expect(TBD.logs.first[:message]).to include("existing PSI set") + TBD.clean! + + # Side test on balcony/sill. + expect(ps.safe("code (Quebec)", :balconysillconcave)).to eq(:balconysill) + + # Defined vs missing conductances. + new_PSI = { id: "foo" } + expect(ps.append(new_PSI)).to be true + + s = ps.shorthands("foo") + expect(TBD.status).to be_zero + expect(s).to be_a(Hash) + expect(s).to have_key(:has) + expect(s).to have_key(:val) + + [:joint, :transition].each do |type| + expect(s[:has]).to have_key(type) + expect(s[:val]).to have_key(type) + expect(s[:has][type]).to be true + expect(s[:val][type]).to be_within(TOL).of(0) + end + + [:balcony, :rimjoist, :fenestration, :parapet].each do |type| + expect(s[:has]).to have_key(type) + expect(s[:val]).to have_key(type) + expect(s[:has][type]).to be false + expect(s[:val][type]).to be_within(TOL).of(0) + end + + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + # Valid JSON entries. + TBD.clean! + + name = "Entryway Wall 5" + file = File.join(__dir__, "files/osms/out/seb2.osm") path = OpenStudio::Path.new(file) model = translator.loadModel(path) expect(model).to_not be_empty model = model.get - # Ensure the plenum is 'unoccupied', i.e. not part of the total floor area. - plnum = model.getSpaceByName("scrigno_plenum") + # Consider the plenum as UNCONDITIONED. + plnum = model.getSpaceByName("Level 0 Ceiling Plenum") expect(plnum).to_not be_empty plnum = plnum.get - expect(plnum.setPartofTotalFloorArea(false)).to be true + expect(TBD.unconditioned?(plnum)).to be false - # As a side test, switch glass doors to (opaque) doors. - model.getSubSurfaces.each do |sub| - next unless sub.subSurfaceType.downcase == "glassdoor" + key = "space_conditioning_category" + val = "Unconditioned" + expect(plnum.additionalProperties.hasFeature(key)).to be false + expect(plnum.additionalProperties.setFeature(key, val)).to be true + expect(TBD.plenum?(plnum)).to be true + expect(TBD.unconditioned?(plnum)).to be true + expect(TBD.setpoints(plnum)[:heating]).to be_nil + expect(TBD.setpoints(plnum)[:cooling]).to be_nil + expect(TBD.status).to be_zero - expect(sub.setSubSurfaceType("Door")).to be true - end + argh = {} + argh[:option ] = "(non thermal bridging)" + argh[:io_path ] = File.join(__dir__, "../json/tbd_seb_n4.json") + argh[:schema_path] = File.join(__dir__, "../tbd.schema.json") - # Switching wall/roof edges from/to: - # - "parapet" PSI-factor 0.26 W/K•m - # - "roof" PSI-factor 0.02 W/K•m !! - # - # ... 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) @@ -275,180 +324,63 @@ surfaces = json[:surfaces] expect(TBD.status).to be_zero expect(TBD.logs).to be_empty - expect(surfaces).to be_a Hash - expect(surfaces.size).to eq(31) + expect(surfaces).to be_a(Hash) + expect(surfaces.size).to eq(56) expect(io).to be_a(Hash) expect(io).to have_key(:edges) - expect(io[:edges].size).to eq(77) + expect(io[:edges].size).to eq(80) - n_edges_at_grade = 0 - n_edges_as_balconies = 0 - n_edges_as_balconysills = 0 - n_edges_as_balconydoorsills = 0 - n_edges_as_concave_parapets = 0 - n_edges_as_convex_parapets = 0 - n_edges_as_concave_roofs = 0 - n_edges_as_convex_roofs = 0 - n_edges_as_rimjoists = 0 - n_edges_as_concave_rimjoists = 0 - n_edges_as_convex_rimjoists = 0 - n_edges_as_fenestrations = 0 - n_edges_as_heads = 0 - n_edges_as_sills = 0 - n_edges_as_jambs = 0 - n_edges_as_doorheads = 0 - n_edges_as_doorsills = 0 - n_edges_as_doorjambs = 0 - n_edges_as_skylightjambs = 0 - n_edges_as_concave_jambs = 0 - n_edges_as_convex_jambs = 0 - n_edges_as_corners = 0 - n_edges_as_concave_corners = 0 - n_edges_as_convex_corners = 0 - n_edges_as_transitions = 0 + # As the :building PSI set on file == "(non thermal bridging)", derating + # shouldn't occur at large. However, the JSON file holds a custom edge + # entry for "Entryway Wall 5" : "bad" fenestration perimeters, which + # only derates the host wall itself + surfaces.each do |id, surface| + next unless surface[:boundary] == "outdoors" - io[:edges].each do |edge| - expect(edge).to have_key(:type) + expect(surface).to_not have_key(:ratio) unless id == name + expect(surface[:heatloss]).to be_within(TOL).of(8.89) if id == name + end + end - n_edges_at_grade += 1 if edge[:type] == :grade - n_edges_at_grade += 1 if edge[:type] == :gradeconcave - n_edges_at_grade += 1 if edge[:type] == :gradeconvex - n_edges_as_balconies += 1 if edge[:type] == :balcony - n_edges_as_balconies += 1 if edge[:type] == :balconyconcave - n_edges_as_balconies += 1 if edge[:type] == :balconyconvex - n_edges_as_balconysills += 1 if edge[:type] == :balconysill - n_edges_as_balconysills += 1 if edge[:type] == :balconysillconcave - n_edges_as_balconysills += 1 if edge[:type] == :balconysillconvex - n_edges_as_balconydoorsills += 1 if edge[:type] == :balconydoorsill - n_edges_as_balconydoorsills += 1 if edge[:type] == :balconydoorsillconcave - n_edges_as_balconydoorsills += 1 if edge[:type] == :balconydoorsillconvex - n_edges_as_concave_parapets += 1 if edge[:type] == :parapetconcave - n_edges_as_convex_parapets += 1 if edge[:type] == :parapetconvex - n_edges_as_concave_roofs += 1 if edge[:type] == :roofconcave - n_edges_as_convex_roofs += 1 if edge[:type] == :roofconvex - n_edges_as_rimjoists += 1 if edge[:type] == :rimjoist - n_edges_as_concave_rimjoists += 1 if edge[:type] == :rimjoistconcave - n_edges_as_convex_rimjoists += 1 if edge[:type] == :rimjoistconvex - n_edges_as_fenestrations += 1 if edge[:type] == :fenestration - n_edges_as_heads += 1 if edge[:type] == :head - n_edges_as_heads += 1 if edge[:type] == :headconcave - n_edges_as_heads += 1 if edge[:type] == :headconvex - n_edges_as_sills += 1 if edge[:type] == :sill - n_edges_as_sills += 1 if edge[:type] == :sillconcave - n_edges_as_sills += 1 if edge[:type] == :sillconvex - n_edges_as_jambs += 1 if edge[:type] == :jamb - n_edges_as_concave_jambs += 1 if edge[:type] == :jambconcave - n_edges_as_convex_jambs += 1 if edge[:type] == :jambconvex - n_edges_as_doorheads += 1 if edge[:type] == :doorhead - n_edges_as_doorsills += 1 if edge[:type] == :doorsill - n_edges_as_doorjambs += 1 if edge[:type] == :doorjamb - n_edges_as_skylightjambs += 1 if edge[:type] == :skylightjamb - n_edges_as_skylightjambs += 1 if edge[:type] == :skylightjambconvex - n_edges_as_corners += 1 if edge[:type] == :corner - n_edges_as_concave_corners += 1 if edge[:type] == :cornerconcave - n_edges_as_convex_corners += 1 if edge[:type] == :cornerconvex - n_edges_as_transitions += 1 if edge[:type] == :transition - end - - expect(n_edges_at_grade ).to eq( 0) - expect(n_edges_as_balconies ).to eq( 2) - expect(n_edges_as_balconysills ).to eq( 0) - expect(n_edges_as_balconydoorsills ).to eq( 2) # ... no longer GlassDoors - expect(n_edges_as_concave_parapets ).to eq( 0) # 1x if parapet (not roof) - expect(n_edges_as_convex_parapets ).to eq( 0) # 11x if parapet (not roof) - expect(n_edges_as_concave_roofs ).to eq( 1) - expect(n_edges_as_convex_roofs ).to eq(11) - expect(n_edges_as_rimjoists ).to eq( 5) - expect(n_edges_as_concave_rimjoists).to eq( 5) - expect(n_edges_as_convex_rimjoists ).to eq(18) - expect(n_edges_as_fenestrations ).to eq( 0) - expect(n_edges_as_heads ).to eq( 0) - expect(n_edges_as_sills ).to eq( 0) - expect(n_edges_as_jambs ).to eq( 0) - expect(n_edges_as_concave_jambs ).to eq( 0) - expect(n_edges_as_convex_jambs ).to eq( 0) - expect(n_edges_as_doorheads ).to eq( 2) # GlassDoor != fenestration - expect(n_edges_as_doorjambs ).to eq( 4) # GlassDoor != fenestration - expect(n_edges_as_doorsills ).to eq( 0) # (2x balconydoorsills) - expect(n_edges_as_skylightjambs ).to eq( 1) # along 1" rooftop strip - expect(n_edges_as_corners ).to eq( 0) - expect(n_edges_as_concave_corners ).to eq( 4) - expect(n_edges_as_convex_corners ).to eq(12) - expect(n_edges_as_transitions ).to eq(10) + it "can pre-process UA parameters" do + translator = OpenStudio::OSVersion::VersionTranslator.new + TBD.clean! - # Re-do, without changing door surface types. - file = File.join(__dir__, "files/osms/in/loscrigno.osm") + ref = "code (Quebec)" + 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 - argh = {option: "90.1.22|steel.m|default", parapet: false} - 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.status).to be_zero - expect(TBD.logs).to be_empty - expect(surfaces).to be_a Hash - expect(surfaces.size).to eq(31) - expect(io).to be_a(Hash) - expect(io).to have_key(:edges) - expect(io[:edges].size).to eq(77) - - # roof PSI : 0.02 W/K•m - # - parapet PSI : 0.26 W/K•m - # --------------------------- - # = delta PSI : -0.24 W/K•m - # - # e.g. East & West : reduction of 10.4m x -0.24 W/K•m = -2.496 W/K - # e.g. North : reduction of 36.6m x -0.24 W/K•m = -8.784 W/K - # - # Total length of roof/parapets : 11m + 2x 36.6m + 2x 10.4m = 105m - # ... 105m x -0.24 W/K•m = -25.2 W/K - expect(surfaces["g_E_wall" ][:heatloss]).to be_within(TOL).of( 1.80) # 4.3 = -2.5 - expect(surfaces["g_W_wall" ][:heatloss]).to be_within(TOL).of( 1.80) # 4.3 = -2.5 - expect(surfaces["g_N_wall" ][:heatloss]).to be_within(TOL).of( 7.17) # 15.95 = -8.8 - expect(surfaces["g_S1_wall" ][:heatloss]).to be_within(TOL).of( 1.08) # 1.87 = -0.8 - expect(surfaces["g_S2_wall" ][:heatloss]).to be_within(TOL).of( 0.08) # 1.04 = -1.0 - expect(surfaces["g_S3_wall" ][:heatloss]).to be_within(TOL).of( 5.07) # 8.19 = -3.1 - - expect(surfaces["e_top" ][:heatloss]).to be_within(TOL).of( 0.11) # 1.32 = -1.2 - expect(surfaces["e_E_wall" ][:heatloss]).to be_within(TOL).of( 0.14) # 0.32 = -0.2 - expect(surfaces["e_W_wall" ][:heatloss]).to be_within(TOL).of( 0.14) # 0.32 = -0.2 - expect(surfaces["e_N_wall" ][:heatloss]).to be_within(TOL).of( 0.95) - expect(surfaces["e_S_wall" ][:heatloss]).to be_within(TOL).of( 0.37) # 0.85 = -0.5 - expect(surfaces["e_floor" ][:heatloss]).to be_within(TOL).of( 2.46) - - expect(surfaces["s_E_wall" ][:heatloss]).to be_within(TOL).of( 1.17) - expect(surfaces["s_W_wall" ][:heatloss]).to be_within(TOL).of( 1.17) - expect(surfaces["s_N_wall" ][:heatloss]).to be_within(TOL).of( 1.54) - expect(surfaces["s_S_wall" ][:heatloss]).to be_within(TOL).of( 1.54) - expect(surfaces["s_floor" ][:heatloss]).to be_within(TOL).of( 2.70) + heated = TBD.heatingTemperatureSetpoints?(model) + cooled = TBD.coolingTemperatureSetpoints?(model) + expect(heated).to be true + expect(cooled).to be true - expect(surfaces["p_W1_floor"][:heatloss]).to be_within(TOL).of( 4.23) - expect(surfaces["p_W2_floor"][:heatloss]).to be_within(TOL).of( 1.82) - expect(surfaces["p_W3_floor"][:heatloss]).to be_within(TOL).of( 1.82) - expect(surfaces["p_W4_floor"][:heatloss]).to be_within(TOL).of( 0.58) - expect(surfaces["p_E_floor" ][:heatloss]).to be_within(TOL).of( 5.73) - expect(surfaces["p_N_wall" ][:heatloss]).to be_within(TOL).of(11.44) - expect(surfaces["p_S2_wall" ][:heatloss]).to be_within(TOL).of( 8.16) - expect(surfaces["p_S1_wall" ][:heatloss]).to be_within(TOL).of( 2.04) - expect(surfaces["p_floor" ][:heatloss]).to be_within(TOL).of( 3.07) + model.getSpaces.each do |space| + expect(TBD.unconditioned?(space)).to be false + stpts = TBD.setpoints(space) + expect(stpts).to be_a(Hash) + expect(stpts).to have_key(:heating) + expect(stpts).to have_key(:cooling) - expect(argh).to have_key(:io) - out = JSON.pretty_generate(argh[:io]) - outP = File.join(__dir__, "../json/tbd_loscrigno1.out.json") - File.open(outP, "w") { |outP| outP.puts out } + heating = stpts[:heating] + cooling = stpts[:cooling] + expect(heating).to be_a(Numeric) + expect(cooling).to be_a(Numeric) - # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # - # 4x cases (warehouse.osm): - # - 1x :parapet (default) case - # - 2x :roof case - # - 2x JSON variations - TBD.clean! + if space.nameString == "Zone1 Office" + expect(heating).to be_within(0.1).of(21.1) + expect(cooling).to be_within(0.1).of(23.9) + elsif space.nameString == "Zone2 Fine Storage" + expect(heating).to be_within(0.1).of(15.6) + expect(cooling).to be_within(0.1).of(26.7) + else + expect(heating).to be_within(0.1).of(10.0) + expect(cooling).to be_within(0.1).of(50.0) + end + end ids = { a: "Office Front Wall", b: "Office Left Wall", @@ -461,839 +393,802 @@ i: "Bulk Storage Roof", j: "Bulk Storage Rear Wall", k: "Bulk Storage Left Wall", - l: "Bulk Storage Right Wall" }.freeze + l: "Bulk Storage Right Wall" + }.freeze - # CASE 1: :parapet (default) case. - 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 + id2 = { a: "Office Front Door", + b: "Office Left Wall Door", + c: "Fine Storage Left Door", + d: "Fine Storage Right Door", + e: "Bulk Storage Door-1", + f: "Bulk Storage Door-2", + g: "Bulk Storage Door-3", + h: "Overhead Door 1", + i: "Overhead Door 2", + j: "Overhead Door 3", + k: "Overhead Door 4", + l: "Overhead Door 5", + m: "Overhead Door 6", + n: "Overhead Door 7" + }.freeze - argh = {option: "90.1.22|steel.m|default"} - 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.status).to be_zero - expect(TBD.logs).to be_empty - expect(surfaces).to be_a Hash - expect(surfaces.size).to eq(23) - expect(io).to be_a(Hash) - expect(io).to have_key(:edges) - expect(io[:edges].size).to eq(300) + psi = TBD::PSI.new + shrts = psi.shorthands(ref) - surfaces.each do |id, surface| - expect(ids).to have_value(id) if surface.key?(:edges) - expect(ids).to_not have_value(id) unless surface.key?(:edges) - end + expect(shrts[:has]).to_not be_empty + expect(shrts[:val]).to_not be_empty + has = shrts[:has] + val = shrts[:val] - surfaces.each do |id, surface| - next unless surface.key?(:edges) + expect(has).to_not be_empty + expect(val).to_not be_empty - expect(ids).to have_value(id) - expect(surface).to have_key(:heatloss) - expect(surface).to have_key(:ratio) - h = surface[:heatloss] - s = model.getSurfaceByName(id) - expect(s).to_not be_empty - s = s.get - expect(s.nameString).to eq(id) - expect(s.isConstructionDefaulted).to be false - expect(s.construction.get.nameString).to include(" tbd") - expect(h).to be_within(TOL).of( 8.00) if id == ids[:a] # 50.20 if "poor" - expect(h).to be_within(TOL).of( 4.24) if id == ids[:b] # 24.06 if "poor" - expect(h).to be_within(TOL).of( 17.23) if id == ids[:c] # 87.16 ... - expect(h).to be_within(TOL).of( 6.53) if id == ids[:d] # 22.61 - expect(h).to be_within(TOL).of( 2.30) if id == ids[:e] # 9.15 - expect(h).to be_within(TOL).of( 1.95) if id == ids[:f] # 26.47 - expect(h).to be_within(TOL).of( 2.10) if id == ids[:g] # 27.19 - expect(h).to be_within(TOL).of( 3.00) if id == ids[:h] # 41.36 - expect(h).to be_within(TOL).of( 26.97) if id == ids[:i] # 161.02 - expect(h).to be_within(TOL).of( 5.25) if id == ids[:j] # 62.28 - expect(h).to be_within(TOL).of( 8.06) if id == ids[:k] # 117.87 - expect(h).to be_within(TOL).of( 8.06) if id == ids[:l] # 95.77 + argh = {} + argh[:option ] = "poor (BETBG)" + argh[:seed ] = "./files/osms/in/warehouse.osm" + argh[:io_path ] = File.join(__dir__, "../json/tbd_warehouse10.json") + argh[:schema_path] = File.join(__dir__, "../tbd.schema.json") + argh[:gen_ua ] = true + argh[:ua_ref ] = ref + argh[:version ] = OpenStudio.openStudioVersion - 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.layers[1].nameString).to include("m tbd") - end + TBD.process(model, argh) + expect(TBD.status).to be_zero + expect(TBD.logs).to be_empty - surfaces.each do |id, surface| - if surface.key?(:ratio) # ... vs "poor (BETBG)" - expect(surface[:ratio]).to be_within(0.2).of(-18.3) if id == ids[:b] # -53.0% - expect(surface[:ratio]).to be_within(0.2).of( -3.5) if id == ids[:c] # -15.6% - 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]).to_not eq("outdoors") - end - end + expect(argh).to have_key(:surfaces) + expect(argh).to have_key(:io) + expect(argh[:surfaces]).to be_a(Hash) + expect(argh[:surfaces].size).to eq(23) - # CASE 2: :roof (not default :parapet) case. - 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 + expect(argh[:io]).to be_a(Hash) + expect(argh[:io]).to_not be_empty + expect(argh[:io]).to have_key(:edges) + expect(argh[:io][:edges].size).to eq(300) - argh = {option: "90.1.22|steel.m|default", parapet: false} - 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.status).to be_zero - expect(TBD.logs).to be_empty - expect(surfaces).to be_a Hash - expect(surfaces.size).to eq(23) - expect(io).to be_a(Hash) - expect(io).to have_key(:edges) - expect(io[:edges].size).to eq(300) + argh[:io][:description] = "test" + # Set up 2x heating setpoint (HSTP) "blocks": + # bloc1: spaces/zones with HSTP >= 18C + # bloc2: spaces/zones with HSTP < 18C + # (ref: 2021 Quebec energy code 3.3. UA' trade-off methodology) + # ... could be generalized in the future e.g., more blocks, user-set HSTP. + # + # Determine UA' compliance separately for (i) bloc1 & (ii) bloc2. + # + # Each block's UA' = ∑ U•area + ∑ PSI•length + ∑ KHI•count + blc = { walls: 0, roofs: 0, floors: 0, doors: 0, + windows: 0, skylights: 0, rimjoists: 0, parapets: 0, + trim: 0, corners: 0, balconies: 0, grade: 0, + other: 0 # includes party wall edges, expansion joints, etc. + } - surfaces.each do |id, surface| - expect(ids).to have_value(id) if surface.key?(:edges) - expect(ids).to_not have_value(id) unless surface.key?(:edges) - end + bloc1 = {} + bloc2 = {} + bloc1[:pro] = blc + bloc1[:ref] = blc.clone + bloc2[:pro] = blc.clone + bloc2[:ref] = blc.clone - surfaces.each do |id, surface| - next unless surface.key?(:edges) + argh[:surfaces].each do |id, surface| + expect(surface).to have_key(:deratable) + next unless surface[:deratable] expect(ids).to have_value(id) - expect(surface).to have_key(:heatloss) - expect(surface).to have_key(:ratio) - h = surface[:heatloss] - s = model.getSurfaceByName(id) - expect(s).to_not be_empty - s = s.get - expect(s.nameString).to eq(id) - expect(s.isConstructionDefaulted).to be false - expect(s.construction.get.nameString).to include(" tbd") - expect(h).to be_within(TOL).of( 8.00) if id == ids[:a] # 8.00 ! - expect(h).to be_within(TOL).of( 4.24) if id == ids[:b] # 4.24 ! - expect(h).to be_within(TOL).of( 1.33) if id == ids[:c] # 17.23 - expect(h).to be_within(TOL).of( 4.17) if id == ids[:d] # 6.53 - expect(h).to be_within(TOL).of( 1.47) if id == ids[:e] # 2.30 - expect(h).to be_within(TOL).of( 0.15) if id == ids[:f] # 1.95 - expect(h).to be_within(TOL).of( 0.16) if id == ids[:g] # 2.10 - expect(h).to be_within(TOL).of( 0.23) if id == ids[:h] # 3.00 - expect(h).to be_within(TOL).of( 2.07) if id == ids[:i] # 26.97 - expect(h).to be_within(TOL).of( 0.40) if id == ids[:j] # 5.25 - expect(h).to be_within(TOL).of( 0.62) if id == ids[:k] # 8.06 - expect(h).to be_within(TOL).of( 0.62) if id == ids[:l] # 8.06 - # ! office walls: same results ... no parapet/roof + expect(surface).to have_key(:type) + expect(surface).to have_key(:net ) + expect(surface).to have_key(:u) - 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.layers[1].nameString).to include("m tbd") - end + expect(surface[:net] > TOL).to be true + expect(surface[:u ] > TOL).to be true - surfaces.each do |id, surface| - if surface.key?(:ratio) # ... vs "parapet" - expect(surface[:ratio]).to be_within(0.2).of(-18.3) if id == ids[:b] # ! - expect(surface[:ratio]).to be_within(0.2).of( -0.3) if id == ids[:c] # -3.5% - expect(surface[:ratio]).to be_within(0.2).of( -0.1) if id == ids[:i] # -1.3% - expect(surface[:ratio]).to be_within(0.2).of( -0.1) if id == ids[:j] # -1.3% - # ! office walls: same results ... no parapet/roof + expect(surface[:u]).to be_within(TOL).of(0.48) if id == ids[:a] + expect(surface[:u]).to be_within(TOL).of(0.48) if id == ids[:b] + expect(surface[:u]).to be_within(TOL).of(0.31) if id == ids[:c] + expect(surface[:u]).to be_within(TOL).of(0.48) if id == ids[:d] + expect(surface[:u]).to be_within(TOL).of(0.48) if id == ids[:e] + expect(surface[:u]).to be_within(TOL).of(0.48) if id == ids[:f] + expect(surface[:u]).to be_within(TOL).of(0.48) if id == ids[:g] + expect(surface[:u]).to be_within(TOL).of(0.48) if id == ids[:h] + expect(surface[:u]).to be_within(TOL).of(0.55) if id == ids[:i] + expect(surface[:u]).to be_within(TOL).of(0.64) if id == ids[:j] + expect(surface[:u]).to be_within(TOL).of(0.64) if id == ids[:k] + expect(surface[:u]).to be_within(TOL).of(0.64) if id == ids[:l] + + # Reference values. + expect(surface).to have_key(:ref) + + expect(surface[:ref]).to be_within(TOL).of(0.28) if id == ids[:a] + expect(surface[:ref]).to be_within(TOL).of(0.28) if id == ids[:b] + expect(surface[:ref]).to be_within(TOL).of(0.18) if id == ids[:c] + expect(surface[:ref]).to be_within(TOL).of(0.28) if id == ids[:d] + expect(surface[:ref]).to be_within(TOL).of(0.28) if id == ids[:e] + expect(surface[:ref]).to be_within(TOL).of(0.28) if id == ids[:f] + expect(surface[:ref]).to be_within(TOL).of(0.28) if id == ids[:g] + expect(surface[:ref]).to be_within(TOL).of(0.28) if id == ids[:h] + expect(surface[:ref]).to be_within(TOL).of(0.23) if id == ids[:i] + expect(surface[:ref]).to be_within(TOL).of(0.34) if id == ids[:j] + expect(surface[:ref]).to be_within(TOL).of(0.34) if id == ids[:k] + expect(surface[:ref]).to be_within(TOL).of(0.34) if id == ids[:l] + + expect(surface).to have_key(:heating) + expect(surface).to have_key(:cooling) + bloc = bloc1 + bloc = bloc2 if surface[:heating] < 18 + + if surface[:type ] == :wall + bloc[:pro][:walls ] += surface[:net] * surface[:u ] + bloc[:ref][:walls ] += surface[:net] * surface[:ref] + elsif surface[:type ] == :ceiling + bloc[:pro][:roofs ] += surface[:net] * surface[:u ] + bloc[:ref][:roofs ] += surface[:net] * surface[:ref] else - expect(surface[:boundary]).to_not eq("outdoors") + bloc[:pro][:floors] += surface[:net] * surface[:u ] + bloc[:ref][:floors] += surface[:net] * surface[:ref] end - end - # CASE 3: Same as CASE 1 (:parapet), yet reset to :roof for "Bulk Storage" - # via JSON file. Extra surface-specific heat loss from derating will switch - # between CASE 1 vs CASE 2 values. - 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 + if surface.key?(:doors) + surface[:doors].each do |i, door| + expect(id2).to have_value(i) + expect(door).to_not have_key(:glazed) + expect(door).to have_key(:gross ) + expect(door).to have_key(:u) + expect(door).to have_key(:ref) + expect(door[:gross] > TOL).to be true + expect(door[:ref ] > TOL).to be true + expect(door[:u ] > TOL).to be true + expect(door[:u ]).to be_within(TOL).of(3.98) + bloc[:pro][:doors] += door[:gross] * door[:u ] + bloc[:ref][:doors] += door[:gross] * door[:ref] + end + end - argh = {} - argh[:option ] = "90.1.22|steel.m|default" - argh[:io_path ] = File.join(__dir__, "../json/tbd_warehouse17.json") - argh[:schema_path] = File.join(__dir__, "../tbd.schema.json") + if surface.key?(:skylights) + surface[:skylights].each do |i, skylight| + expect(skylight).to have_key(:gross) + expect(skylight).to have_key(:u) + expect(skylight).to have_key(:ref) + expect(skylight[:gross] > TOL).to be true + expect(skylight[:ref ] > TOL).to be true + expect(skylight[:u ] > TOL).to be true + expect(skylight[:u ]).to be_within(TOL).of(6.64) + bloc[:pro][:skylights] += skylight[:gross] * skylight[:u ] + bloc[:ref][:skylights] += skylight[:gross] * skylight[:ref] + end + end - 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.status).to be_zero - expect(TBD.logs).to be_empty - expect(surfaces).to be_a Hash - expect(surfaces.size).to eq(23) - expect(io).to be_a(Hash) - expect(io).to have_key(:edges) - expect(io[:edges].size).to eq(300) + id3 = { a: "Office Front Wall Window 1", + b: "Office Front Wall Window2" + }.freeze - surfaces.each do |id, surface| - expect(ids).to have_value(id) if surface.key?(:edges) - expect(ids).to_not have_value(id) unless surface.key?(:edges) - end + if surface.key?(:windows) + surface[:windows].each do |i, window| + expect(window).to have_key(:u) + expect(window).to have_key(:ref) + expect(window[:ref] > TOL).to be true - surfaces.each do |id, surface| - next unless surface.key?(:edges) + bloc[:pro][:windows] += window[:gross] * window[:u ] + bloc[:ref][:windows] += window[:gross] * window[:ref] - expect(ids).to have_value(id) - expect(surface).to have_key(:heatloss) - expect(surface).to have_key(:ratio) - h = surface[:heatloss] - s = model.getSurfaceByName(id) - expect(s).to_not be_empty - s = s.get - expect(s.nameString).to eq(id) - expect(s.isConstructionDefaulted).to be false - expect(s.construction.get.nameString).to include(" tbd") - expect(h).to be_within(TOL).of( 8.00) if id == ids[:a] # ! - expect(h).to be_within(TOL).of( 4.24) if id == ids[:b] # ! - expect(h).to be_within(TOL).of( 17.23) if id == ids[:c] - expect(h).to be_within(TOL).of( 6.53) if id == ids[:d] - expect(h).to be_within(TOL).of( 2.30) if id == ids[:e] - expect(h).to be_within(TOL).of( 1.95) if id == ids[:f] - expect(h).to be_within(TOL).of( 2.10) if id == ids[:g] - expect(h).to be_within(TOL).of( 3.00) if id == ids[:h] - expect(h).to be_within(TOL).of( 2.07) if id == ids[:i] # Bulk - expect(h).to be_within(TOL).of( 0.40) if id == ids[:j] # Bulk - expect(h).to be_within(TOL).of( 0.62) if id == ids[:k] # Bulk - expect(h).to be_within(TOL).of( 0.62) if id == ids[:l] # Bulk - # ! office walls: same results ... no parapet/roof + expect(window[:u ] > 0).to be true + expect(window[:u ]).to be_within(TOL).of(4.00) if i == id3[:a] + expect(window[:u ]).to be_within(TOL).of(3.50) if i == id3[:b] + expect(window[:gross]).to be_within(TOL).of(5.58) if i == id3[:a] + expect(window[:gross]).to be_within(TOL).of(5.58) if i == id3[:b] - 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.layers[1].nameString).to include("m tbd") - end + next if [id3[:a], id3[:b]].include?(i) - surfaces.each do |id, surface| - if surface.key?(:ratio) - expect(surface[:ratio]).to be_within(0.2).of(-18.3) if id == ids[:b] # ! - expect(surface[:ratio]).to be_within(0.2).of( -3.5) if id == ids[:c] - expect(surface[:ratio]).to be_within(0.2).of( -0.1) if id == ids[:i] # Bulk - 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]).to_not eq("outdoors") + expect(window[:gross]).to be_within(TOL).of(3.25) + expect(window[:u ]).to be_within(TOL).of(2.35) + end end - end - # CASE 4: Same as CASE 3 (:parapet, reset to :roof for "Bulk Storage" - # via JSON file), yet wall/roof edge along "Bulk Storage Rear Wall", - # ids[:j], is reset to :parapet (via JSON file). Again, extra surface - # -specific heat loss from derating will switch between CASE 1 vs CASE 2 - # values (either one or the other). Exceptionally in the case of the "Bulk - # Storage Roof", the extra heat loss (and derating %) are greater somewhat - # (vs CASE 3), as it remains affected by the (unaltered) :roof edges along: - # - # - "Bulk Storage Left Wall" - # - "Bulk Storage Right Wall" - # - 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 - - argh = {} - argh[:option ] = "90.1.22|steel.m|default" - argh[:io_path ] = File.join(__dir__, "../json/tbd_warehouse18.json") - argh[:schema_path] = File.join(__dir__, "../tbd.schema.json") - - 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.status).to be_zero - expect(TBD.logs).to be_empty - expect(surfaces).to be_a Hash - expect(surfaces.size).to eq(23) - expect(io).to be_a(Hash) - expect(io).to have_key(:edges) - expect(io[:edges].size).to eq(300) - - surfaces.each do |id, surface| - expect(ids).to have_value(id) if surface.key?(:edges) - expect(ids).to_not have_value(id) unless surface.key?(:edges) - end + if surface.key?(:edges) + surface[:edges].values.each do |edge| + expect(edge).to have_key(:type ) + expect(edge).to have_key(:ratio) + expect(edge).to have_key(:ref ) + expect(edge).to have_key(:psi ) + next unless edge[:psi] > TOL - surfaces.each do |id, surface| - next unless surface.key?(:edges) + tt = psi.safe(ref, edge[:type]) + expect(tt).to_not be_nil - expect(ids).to have_value(id) - expect(surface).to have_key(:heatloss) - expect(surface).to have_key(:ratio) - h = surface[:heatloss] - s = model.getSurfaceByName(id) - expect(s).to_not be_empty - s = s.get - expect(s.nameString).to eq(id) - expect(s.isConstructionDefaulted).to be false - expect(s.construction.get.nameString).to include(" tbd") - expect(h).to be_within(TOL).of( 8.00) if id == ids[:a] # ! - expect(h).to be_within(TOL).of( 4.24) if id == ids[:b] # ! - expect(h).to be_within(TOL).of( 17.23) if id == ids[:c] - expect(h).to be_within(TOL).of( 6.53) if id == ids[:d] - expect(h).to be_within(TOL).of( 2.30) if id == ids[:e] - expect(h).to be_within(TOL).of( 1.95) if id == ids[:f] - expect(h).to be_within(TOL).of( 2.10) if id == ids[:g] - expect(h).to be_within(TOL).of( 3.00) if id == ids[:h] - expect(h).to be_within(TOL).of( 8.20) if id == ids[:i] # 2.07 < x < 26.97 - expect(h).to be_within(TOL).of( 5.25) if id == ids[:j] # Bulk Rear Wall - expect(h).to be_within(TOL).of( 0.62) if id == ids[:k] # Bulk - expect(h).to be_within(TOL).of( 0.62) if id == ids[:l] # Bulk - # ! office walls: same results ... no parapet/roof + expect(edge[:ref]).to be_within(TOL).of(val[tt] * edge[:ratio]) + rate = edge[:ref] / edge[:psi] * 100 - 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.layers[1].nameString).to include("m tbd") - end + case tt + when :rimjoist + expect(rate).to be_within(0.1).of(30.0) + bloc[:pro][:rimjoists] += edge[:length] * edge[:psi ] + bloc[:ref][:rimjoists] += edge[:length] * edge[:ratio] * val[tt] + when :parapet + expect(rate).to be_within(0.1).of(40.6) + bloc[:pro][:parapets ] += edge[:length] * edge[:psi ] + bloc[:ref][:parapets ] += edge[:length] * edge[:ratio] * val[tt] + when :fenestration + expect(rate).to be_within(0.1).of(40.0) + bloc[:pro][:trim ] += edge[:length] * edge[:psi ] + bloc[:ref][:trim ] += edge[:length] * edge[:ratio] * val[tt] + when :door + expect(rate).to be_within(0.1).of(40.0) + bloc[:pro][:trim ] += edge[:length] * edge[:psi ] + bloc[:ref][:trim ] += edge[:length] * edge[:ratio] * val[tt] + when :skylight + expect(rate).to be_within(0.1).of(40.0) + bloc[:pro][:trim ] += edge[:length] * edge[:psi ] + bloc[:ref][:trim ] += edge[:length] * edge[:ratio] * val[tt] + when :corner + expect(rate).to be_within(0.1).of(35.3) + bloc[:pro][:corners ] += edge[:length] * edge[:psi ] + bloc[:ref][:corners ] += edge[:length] * edge[:ratio] * val[tt] + when :grade + expect(rate).to be_within(0.1).of(52.9) + bloc[:pro][:grade ] += edge[:length] * edge[:psi ] + bloc[:ref][:grade ] += edge[:length] * edge[:ratio] * val[tt] + else + expect(rate).to be_within(0.1).of( 0.0) + bloc[:pro][:other ] += edge[:length] * edge[:psi ] + bloc[:ref][:other ] += edge[:length] * edge[:ratio] * val[tt] + end + end + end - surfaces.each do |id, surface| - if surface.key?(:ratio) - expect(surface[:ratio]).to be_within(0.2).of(-18.3) if id == ids[:b] # ! - expect(surface[:ratio]).to be_within(0.2).of( -3.5) if id == ids[:c] - expect(surface[:ratio]).to be_within(0.2).of( -0.4) if id == ids[:i] # 0.1 < x < 1.3% - 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]).to_not eq("outdoors") + if surface.key?(:pts) + surface[:pts].values.each do |pts| + expect(pts).to have_key(:val) + expect(pts).to have_key(:n) + expect(pts).to have_key(:ref) + bloc[:pro][:other] += pts[:val] * pts[:n] + bloc[:ref][:other] += pts[:ref] * pts[:n] + end end end - end - it "can process DOE Prototype smalloffice.osm" do - translator = OpenStudio::OSVersion::VersionTranslator.new - TBD.clean! + expect(bloc1[:pro][:walls ]).to be_within(0.1).of( 60.1) + expect(bloc1[:pro][:roofs ]).to be_within(0.1).of( 0.0) + expect(bloc1[:pro][:floors ]).to be_within(0.1).of( 0.0) + expect(bloc1[:pro][:doors ]).to be_within(0.1).of( 23.3) + expect(bloc1[:pro][:windows ]).to be_within(0.1).of( 57.1) + expect(bloc1[:pro][:skylights]).to be_within(0.1).of( 0.0) + expect(bloc1[:pro][:rimjoists]).to be_within(0.1).of( 17.5) + expect(bloc1[:pro][:parapets ]).to be_within(0.1).of( 0.0) + expect(bloc1[:pro][:trim ]).to be_within(0.1).of( 23.3) + expect(bloc1[:pro][:corners ]).to be_within(0.1).of( 3.6) + expect(bloc1[:pro][:balconies]).to be_within(0.1).of( 0.0) + expect(bloc1[:pro][:grade ]).to be_within(0.1).of( 29.8) + expect(bloc1[:pro][:other ]).to be_within(0.1).of( 0.0) - file = File.join(__dir__, "files/osms/in/smalloffice.osm") - path = OpenStudio::Path.new(file) - model = translator.loadModel(path) - expect(model).to_not be_empty - model = model.get + bloc1_pro_UA = bloc1[:pro].values.sum + bloc1_ref_UA = bloc1[:ref].values.sum + bloc2_pro_UA = bloc2[:pro].values.sum + bloc2_ref_UA = bloc2[:ref].values.sum - model.getSpaces.each do |space| - expect(space.thermalZone).to_not be_empty - zone = space.thermalZone.get + expect(bloc1_pro_UA).to be_within(0.1).of( 214.8) + expect(bloc1_ref_UA).to be_within(0.1).of( 107.2) + expect(bloc2_pro_UA).to be_within(0.1).of(4863.6) + expect(bloc2_ref_UA).to be_within(0.1).of(2275.4) - heat_spt = TBD.maxHeatScheduledSetpoint(zone) - cool_spt = TBD.minCoolScheduledSetpoint(zone) - expect(heat_spt).to have_key(:spt) - expect(cool_spt).to have_key(:spt) + expect(bloc1[:ref][:walls ]).to be_within(0.1).of( 35.0) + expect(bloc1[:ref][:roofs ]).to be_within(0.1).of( 0.0) + expect(bloc1[:ref][:floors ]).to be_within(0.1).of( 0.0) + expect(bloc1[:ref][:doors ]).to be_within(0.1).of( 5.3) + expect(bloc1[:ref][:windows ]).to be_within(0.1).of( 35.3) + expect(bloc1[:ref][:skylights]).to be_within(0.1).of( 0.0) + expect(bloc1[:ref][:rimjoists]).to be_within(0.1).of( 5.3) + expect(bloc1[:ref][:parapets ]).to be_within(0.1).of( 0.0) + expect(bloc1[:ref][:trim ]).to be_within(0.1).of( 9.3) + expect(bloc1[:ref][:corners ]).to be_within(0.1).of( 1.3) + expect(bloc1[:ref][:balconies]).to be_within(0.1).of( 0.0) + expect(bloc1[:ref][:grade ]).to be_within(0.1).of( 15.8) + expect(bloc1[:ref][:other ]).to be_within(0.1).of( 0.0) - heating = heat_spt[:spt] - cooling = cool_spt[:spt] - stpts = TBD.setpoints(space) - expect(stpts).to have_key(:heating) - expect(stpts).to have_key(:cooling) + expect(bloc2[:pro][:walls ]).to be_within(0.1).of(1342.0) + expect(bloc2[:pro][:roofs ]).to be_within(0.1).of(2169.2) + expect(bloc2[:pro][:floors ]).to be_within(0.1).of( 0.0) + expect(bloc2[:pro][:doors ]).to be_within(0.1).of( 245.6) + expect(bloc2[:pro][:windows ]).to be_within(0.1).of( 0.0) + expect(bloc2[:pro][:skylights]).to be_within(0.1).of( 454.3) + expect(bloc2[:pro][:rimjoists]).to be_within(0.1).of( 17.5) + expect(bloc2[:pro][:parapets ]).to be_within(0.1).of( 234.1) + expect(bloc2[:pro][:trim ]).to be_within(0.1).of( 155.0) + expect(bloc2[:pro][:corners ]).to be_within(0.1).of( 25.4) + expect(bloc2[:pro][:balconies]).to be_within(0.1).of( 0.0) + expect(bloc2[:pro][:grade ]).to be_within(0.1).of( 218.9) + expect(bloc2[:pro][:other ]).to be_within(0.1).of( 1.6) - if zone.nameString == "Attic ZN" - expect(heating).to be_nil - expect(cooling).to be_nil - expect(stpts[:heating]).to be_nil - expect(stpts[:cooling]).to be_nil - expect(zone.isPlenum).to be false - expect(TBD.plenum?(space)).to be false - next - end + expect(bloc2[:ref][:walls ]).to be_within(0.1).of( 732.0) + expect(bloc2[:ref][:roofs ]).to be_within(0.1).of( 961.8) + expect(bloc2[:ref][:floors ]).to be_within(0.1).of( 0.0) + expect(bloc2[:ref][:doors ]).to be_within(0.1).of( 67.5) + expect(bloc2[:ref][:windows ]).to be_within(0.1).of( 0.0) + expect(bloc2[:ref][:skylights]).to be_within(0.1).of( 225.9) + expect(bloc2[:ref][:rimjoists]).to be_within(0.1).of( 5.3) + expect(bloc2[:ref][:parapets ]).to be_within(0.1).of( 95.1) + expect(bloc2[:ref][:trim ]).to be_within(0.1).of( 62.0) + expect(bloc2[:ref][:corners ]).to be_within(0.1).of( 9.0) + expect(bloc2[:ref][:balconies]).to be_within(0.1).of( 0.0) + expect(bloc2[:ref][:grade ]).to be_within(0.1).of( 115.9) + expect(bloc2[:ref][:other ]).to be_within(0.1).of( 1.0) - expect(TBD.plenum?(space)).to be false - expect(heating).to be_within(0.1).of(21.1) - expect(cooling).to be_within(0.1).of(23.9) - expect(stpts[:heating]).to_not be_nil - expect(stpts[:cooling]).to_not be_nil - expect(stpts[:heating]).to be_within(0.1).of(21.1) - expect(stpts[:cooling]).to be_within(0.1).of(23.9) - end + # Testing summaries function. + ua = TBD.ua_summary(Time.now, argh) + expect(ua).to_not be_nil + expect(ua).to_not be_empty + expect(ua).to be_a(Hash) + expect(ua).to have_key(:model) + expect(ua).to have_key(:fr) - # Tracking insulated ceiling surfaces below attic. - model.getSurfaces.each do |s| - next unless s.surfaceType == "RoofCeiling" - next unless s.isConstructionDefaulted + expect(ua[:fr]).to have_key(:objective) + expect(ua[:fr]).to have_key(:details) + expect(ua[:fr]).to have_key(:areas) + expect(ua[:fr]).to have_key(:notes) - c = s.construction - expect(c).to_not be_empty - c = c.get.to_LayeredConstruction - expect(c).to_not be_empty - c = c.get + expect(ua[:fr][:objective]).to_not be_empty - id = c.nameString - expect(id).to eq("Typical Wood Joist Attic Floor R-37.04 1") - expect(c.layers.size).to eq(2) - expect(c.layers[0].nameString).to eq("5/8 in. Gypsum Board") - expect(c.layers[1].nameString).to eq("Typical Insulation R-35.4 1") - # "5/8 in. Gypsum Board" : RSi = 0,0994 m2.K/W - # "Typical Insulation R-35.4 1" : RSi = 6,2348 m2.K/W - end + expect(ua[:fr][:details]).to be_a(Array) + expect(ua[:fr][:details]).to_not be_empty - # Tracking outdoor-facing office walls. - model.getSurfaces.each do |s| - next unless s.surfaceType == "Wall" - next unless s.outsideBoundaryCondition.downcase == "outdoors" + expect(ua[:fr][:areas]).to be_a(Hash) + expect(ua[:fr][:areas]).to_not be_empty + expect(ua[:fr][:areas]).to have_key(:walls) + expect(ua[:fr][:areas]).to have_key(:roofs) + expect(ua[:fr][:areas]).to_not have_key(:floors) + expect(ua[:fr][:notes]).to_not be_empty - id = s.construction.get.nameString - str = "Typical Insulated Wood Framed Exterior Wall R-11.24" - expect(id).to include(str) + expect(ua[:fr]).to have_key(:b1) + expect(ua[:fr][:b1]).to_not be_empty + expect(ua[:fr][:b1]).to have_key(:summary) + expect(ua[:fr][:b1]).to have_key(:walls) + expect(ua[:fr][:b1]).to have_key(:doors) + expect(ua[:fr][:b1]).to have_key(:windows) + expect(ua[:fr][:b1]).to have_key(:rimjoists) + expect(ua[:fr][:b1]).to have_key(:trim) + expect(ua[:fr][:b1]).to have_key(:corners) + expect(ua[:fr][:b1]).to have_key(:grade) + expect(ua[:fr][:b1]).to_not have_key(:roofs) + expect(ua[:fr][:b1]).to_not have_key(:floors) + expect(ua[:fr][:b1]).to_not have_key(:skylights) + expect(ua[:fr][:b1]).to_not have_key(:parapets) + expect(ua[:fr][:b1]).to_not have_key(:balconies) + expect(ua[:fr][:b1]).to_not have_key(:other) - c = s.construction - expect(c).to_not be_empty - c = c.get.to_LayeredConstruction - expect(c).to_not be_empty - c = c.get + expect(ua[:fr]).to have_key(:b2) + expect(ua[:fr][:b2]).to_not be_empty + expect(ua[:fr][:b2]).to have_key(:summary) + expect(ua[:fr][:b2]).to have_key(:walls) + expect(ua[:fr][:b2]).to have_key(:roofs) + expect(ua[:fr][:b2]).to have_key(:doors) + expect(ua[:fr][:b2]).to have_key(:skylights) + expect(ua[:fr][:b2]).to have_key(:rimjoists) + expect(ua[:fr][:b2]).to have_key(:parapets) + expect(ua[:fr][:b2]).to have_key(:trim) + expect(ua[:fr][:b2]).to have_key(:corners) + expect(ua[:fr][:b2]).to have_key(:grade) + expect(ua[:fr][:b2]).to have_key(:other) + expect(ua[:fr][:b2]).to_not have_key(:floors) + expect(ua[:fr][:b2]).to_not have_key(:windows) + expect(ua[:fr][:b2]).to_not have_key(:balconies) - expect(c.layers.size).to eq(4) - expect(c.layers[0].nameString).to eq("25mm Stucco") - expect(c.layers[1].nameString).to eq("5/8 in. Gypsum Board") - str2 = "Typical Insulation R-9.06 1" - expect(c.layers[2].nameString).to include(str2) - expect(c.layers[3].nameString).to eq("5/8 in. Gypsum Board") - # "25mm Stucco" : RSi = 0,0353 m2.K/W - # "5/8 in. Gypsum Board" : RSi = 0,0994 m2.K/W - # "Perimeter_ZN_1_wall_south Typical Insulation R-9.06 1" - # : RSi = 0,5947 m2.K/W - # "Perimeter_ZN_2_wall_east Typical Insulation R-9.06 1" - # : RSi = 0,6270 m2.K/W - # "Perimeter_ZN_3_wall_north Typical Insulation R-9.06 1" - # : RSi = 0,6346 m2.K/W - # "Perimeter_ZN_4_wall_west Typical Insulation R-9.06 1" - # : RSi = 0,6270 m2.K/W - end + expect(ua[:en]).to have_key(:b1) + expect(ua[:en][:b1]).to_not be_empty + expect(ua[:en][:b1]).to have_key(:summary) + expect(ua[:en][:b1]).to have_key(:walls) + expect(ua[:en][:b1]).to have_key(:doors) + expect(ua[:en][:b1]).to have_key(:windows) + expect(ua[:en][:b1]).to have_key(:rimjoists) + expect(ua[:en][:b1]).to have_key(:trim) + expect(ua[:en][:b1]).to have_key(:corners) + expect(ua[:en][:b1]).to have_key(:grade) + expect(ua[:en][:b1]).to_not have_key(:roofs) + expect(ua[:en][:b1]).to_not have_key(:floors) + expect(ua[:en][:b1]).to_not have_key(:skylights) + expect(ua[:en][:b1]).to_not have_key(:parapets ) + expect(ua[:en][:b1]).to_not have_key(:balconies) + expect(ua[:en][:b1]).to_not have_key(:other) - argh = { option: "poor (BETBG)" } + expect(ua[:en]).to have_key(:b2) + expect(ua[:en][:b2]).to_not be_empty + expect(ua[:en][:b2]).to have_key(:summary) + expect(ua[:en][:b2]).to have_key(:walls) + expect(ua[:en][:b2]).to have_key(:roofs) + expect(ua[:en][:b2]).to have_key(:doors) + expect(ua[:en][:b2]).to have_key(:skylights) + expect(ua[:en][:b2]).to have_key(:rimjoists) + expect(ua[:en][:b2]).to have_key(:parapets) + expect(ua[:en][:b2]).to have_key(:trim) + expect(ua[:en][:b2]).to have_key(:corners) + expect(ua[:en][:b2]).to have_key(:grade) + expect(ua[:en][:b2]).to have_key(:other) + expect(ua[:en][:b2]).to_not have_key(:floors) + expect(ua[:en][:b2]).to_not have_key(:windows) + expect(ua[:en][:b2]).to_not have_key(:balconies) - 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] + ud_md_en = TBD.ua_md(ua, :en) + ud_md_fr = TBD.ua_md(ua, :fr) + path_en = File.join(__dir__, "files/ua/ua_en.md") + path_fr = File.join(__dir__, "files/ua/ua_fr.md") + + File.open(path_en, "w") { |file| file.puts ud_md_en } + File.open(path_fr, "w") { |file| file.puts ud_md_fr } + + # Try with an incomplete reference, e.g. (non thermal bridging). + TBD.clean! + + 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 + + # When faced with an edge that may be characterized by more than one thermal + # bridge type (e.g. ground-floor door "sill" vs "grade" edge; "corner" vs + # corner window "jamb"), TBD retains the edge type (amongst candidate edge + # types) representing the greatest heat loss: + # + # psi = edge[:psi].values.max + # type = edge[:psi].key(psi) + # + # As long as there is a slight difference in PSI-factors between candidate + # edge types, the automated selection will be deterministic. With 2 or more + # edge types sharing the exact same PSI-factor (e.g. 0.3 W/K per m), the + # final edge type selection becomes less obvious. It is not randomly + # selected, but rather based on the (somewhat arbitrary) design choice of + # which edge type is processed first in psi.rb (line ~1300 onwards). For + # instance, fenestration perimeters are treated before corners or parapets. + # When dealing with equal hash values, Ruby's Hash "key" method + # returns the first key (i.e. edge type) that matches the criterion: + # + # https://docs.ruby-lang.org/en/2.0.0/Hash.html#method-i-key + # + # From an energy simulation results perspective, the consequences of this + # pseudo-random choice are insignificant (i.e. ~same PSI-factor). For UA' + # comparisons, the situation becomes less obvious in outlier cases. When a + # reference value needs to be generated for a given edge, TBD retains the + # original autoselected edge type, yet applies reference PSI values (e.g. + # "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 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. + # + # This overview remains an "aide-mémoire" for future guide material. + argh[:io ] = nil + argh[:surfaces ] = nil + argh[:option ] = "(non thermal bridging)" + argh[:io_path ] = nil + argh[:schema_path] = nil + argh[:gen_ua ] = true + argh[:ua_ref ] = ref + + TBD.process(model, argh) expect(TBD.status).to be_zero expect(TBD.logs).to be_empty - expect(surfaces).to be_a(Hash) - expect(surfaces.size).to eq(43) - expect(io).to be_a(Hash) - expect(io).to have_key(:edges) - expect(io[:edges].size).to eq(105) + expect(argh).to have_key(:surfaces) + expect(argh).to have_key(:io) - surfaces.each do |id, surface| - expect(surface).to have_key(:conditioned) - next unless surface[:conditioned] + expect(argh[:surfaces]).to be_a(Hash) + expect(argh[:surfaces].size).to eq(23) - expect(surface).to have_key(:heating) - expect(surface).to have_key(:cooling) + expect(argh[:io]).to be_a(Hash) + expect(argh[:io]).to have_key(:edges) + expect(argh[:io][:edges].size).to eq(300) - # Testing glass door detection - if surface.key?(:doors) - surface[:doors].each do |i, door| - expect(door).to have_key(:glazed) - expect(door).to have_key(:u) - expect(door[:glazed]).to be true - expect(door[:u ]).to be_a(Numeric) - expect(door[:u ]).to be_within(TOL).of(6.40) - end - end - end + # Testing summaries function. + argh[:io][:description] = "testing non thermal bridging" - # Testing attic surfaces. - surfaces.each do |id, surface| - expect(surface).to have_key(:space) - next unless surface[:space].nameString == "Attic" + ua = TBD.ua_summary(Time.now, argh) + expect(ua).to_not be_nil + expect(ua).to be_a(Hash) + expect(ua).to_not be_empty + expect(ua).to have_key(:model) - # Attic is an UNENCLOSED zone - outdoor-facing surfaces are not derated. - expect(surface).to have_key(:filmRSI) - expect(surface).to have_key(:conditioned) - expect(surface[:conditioned]).to be false - expect(surface).to_not have_key(:heatloss) - expect(surface).to_not have_key(:ratio) + en_ud_md = TBD.ua_md(ua, :en) + fr_ud_md = TBD.ua_md(ua, :fr) + path_en = File.join(__dir__, "files/ua/en_ua.md") + path_fr = File.join(__dir__, "files/ua/fr_ua.md") + File.open(path_en, "w") { |file| file.puts en_ud_md } + File.open(path_fr, "w") { |file| file.puts fr_ud_md } + end - # Attic floor surfaces adjacent to ceiling surfaces below (CONDITIONED - # office spaces) share derated constructions (although inverted). - expect(surface).to have_key(:boundary) - b = surface[:boundary] - next if b == "outdoors" + it "can work off of a cloned model" do + translator = OpenStudio::OSVersion::VersionTranslator.new + TBD.clean! - # TBD/Topolys should be tracking the adjacent CONDITIONED surface. - expect(surfaces).to have_key(b) - expect(surfaces[b]).to be_a(Hash) - expect(surfaces[b]).to have_key(:conditioned) - expect(surfaces[b][:conditioned]).to be true + argh1 = { option: "poor (BETBG)" } + argh2 = { option: "poor (BETBG)" } + argh3 = { option: "poor (BETBG)" } - if id == "Attic_floor_core" - expect(surfaces[b]).to_not have_key(:ratio) - expect(surfaces[b]).to have_key(:heatloss) - expect(surfaces[b][:heatloss]).to be_within(TOL).of(0.00) - end + 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 + mdl = model.clone + fil = File.join(__dir__, "files/osms/out/alt_warehouse.osm") + mdl.save(fil, true) - next if id == "Attic_floor_core" + # Despite one being the clone of the other, files will not be identical, + # namely due to unique handles. + expect(FileUtils).to_not be_identical(file, fil) - expect(surfaces[b]).to have_key(:heatloss) - h = surfaces[b][:heatloss] - expect(h).to be_within(TOL).of(20.11) if id.include?("north") - expect(h).to be_within(TOL).of(20.22) if id.include?("south") - expect(h).to be_within(TOL).of(13.42) if id.include?( "west") - expect(h).to be_within(TOL).of(13.42) if id.include?( "east") + TBD.process(model, argh1) + expect(TBD.status).to be_zero + expect(TBD.logs).to be_empty - # Derated constructions? - s = model.getSurfaceByName(id) - expect(s).to_not be_empty - s = s.get - expect(s.nameString).to eq(id) - expect(s.surfaceType).to eq("Floor") + expect(argh1).to have_key(:surfaces) + expect(argh1).to have_key(:io) - # In the small office OSM, attic floor constructions are not set by - # the attic default construction set. They are instead set for the - # adjacent ceilings below (building default construction set). So - # attic floor surfaces automatically inherit derated constructions. - expect(s.isConstructionDefaulted).to be true - c = s.construction.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(2) - expect(c.layers[0].nameString).to eq("5/8 in. Gypsum Board") - expect(c.layers[1].nameString).to include("m tbd") + expect(argh1[:surfaces]).to be_a(Hash) + expect(argh1[:surfaces].size).to eq(23) - # Comparing derating ratios of constructions. - expect(c.layers[1].to_MasslessOpaqueMaterial).to_not be_empty - m = c.layers[1].to_MasslessOpaqueMaterial.get - expect(surface[:filmRSI].round(4)).to eq(0.2665) + expect(argh1[:io]).to be_a(Hash) + expect(argh1[:io]).to have_key(:edges) + expect(argh1[:io][:edges].size).to eq(300) - # Before derating. - # "5/8 in. Gypsum Board" : RSi = 0.0994 m2.K/W - # "Typical Insulation R-35.4 1" : RSi = 6.2348 m2.K/W - # surface air film resistances : RSi = 0.2665 m2.K/W - # ----------------------------- ------------------- - # RSi = 6.6007 m2.K/W - initial_R = surface[:filmRSI] - initial_R += 0.0994 - initial_R += 6.2348 - expect(initial_R.round(3)).to eq(6.601) + out = JSON.pretty_generate(argh1[:io]) + outP = File.join(__dir__, "../json/tbd_warehouse12.out.json") + File.open(outP, "w") { |outP| outP.puts out } - # After derating. - derated_R = surface[:filmRSI] - derated_R += 0.0994 - derated_R += m.thermalResistance + TBD.clean! + fil = File.join(__dir__, "files/osms/out/alt_warehouse.osm") + pth = OpenStudio::Path.new(fil) + mdl = translator.loadModel(pth) + expect(mdl).to_not be_empty + mdl = mdl.get - ratio = -(initial_R - derated_R) * 100 / initial_R - expect(ratio).to be_within(1).of(surfaces[b][:ratio]) - end + TBD.process(mdl, argh2) + expect(TBD.status).to be_zero + expect(TBD.logs).to be_empty - surfaces.each do |id, surface| - next unless surface.key?(:edges) + expect(argh2).to have_key(:surfaces) + expect(argh2).to have_key(:io) - expect(surface).to have_key(:heatloss) + expect(argh2[:surfaces]).to be_a(Hash) + expect(argh2[:surfaces].size).to eq(23) - if id == "Core_ZN_ceiling" - expect(surface[:heatloss]).to be_within(0.001).of(0) - expect(surface).to_not have_key(:ratio) - expect(surface).to have_key(:u) - expect(surface[:u]).to be_within(0.001).of(0.152) - next - end + expect(argh2[:io]).to be_a(Hash) + expect(argh2[:io]).to have_key(:edges) + expect(argh2[:io][:edges].size).to eq(300) - expect(surface).to have_key(:ratio) - h = surface[:heatloss] - s = model.getSurfaceByName(id) - expect(s).to_not be_empty - s = s.get - expect(s.nameString).to eq(id) - expect(s.isConstructionDefaulted).to be false - expect(s.construction.get.nameString).to include(" tbd") - next unless s.surfaceType == "Wall" + # The JSON output files are identical. + out2 = JSON.pretty_generate(argh2[:io]) + outP2 = File.join(__dir__, "../json/tbd_warehouse13.out.json") + File.open(outP2, "w") { |outP2| outP2.puts out2 } + expect(FileUtils).to be_identical(outP, outP2) - expect(h).to be_within(TOL).of(51.17) if id.include?("_1_") # South - expect(h).to be_within(TOL).of(33.08) if id.include?("_2_") # East - expect(h).to be_within(TOL).of(48.32) if id.include?("_3_") # North - expect(h).to be_within(TOL).of(33.08) if id.include?("_4_") # West + time = Time.now - 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.layers.size).to eq(4) - expect(c.layers[2].nameString).to include("m tbd") - next unless id.include?("_1_") # South + # Original output UA' MD file. + argh1[:ua_ref ] = "code (Quebec)" + argh1[:io][:description] = "testing equality" + argh1[:version ] = OpenStudio.openStudioVersion + argh1[:seed ] = File.join(__dir__, "files/osms/in/warehouse.osm") - l_fen = 0 - l_head = 0 - l_sill = 0 - l_jamb = 0 - l_grade = 0 - l_parapet = 0 - l_corner = 0 + o_ua = TBD.ua_summary(time, argh1) + expect(o_ua).to_not be_nil + expect(o_ua).to_not be_empty + expect(o_ua).to be_a(Hash) + expect(o_ua).to have_key(:model) - surface[:edges].values.each do |edge| - l_fen += edge[:length] if edge[:type] == :fenestration - l_head += edge[:length] if edge[:type] == :head - l_sill += edge[:length] if edge[:type] == :sill - l_jamb += edge[:length] if edge[:type] == :jamb - l_grade += edge[:length] if edge[:type] == :grade - l_grade += edge[:length] if edge[:type] == :gradeconcave - l_grade += edge[:length] if edge[:type] == :gradeconvex - l_parapet += edge[:length] if edge[:type] == :parapet - l_parapet += edge[:length] if edge[:type] == :parapetconcave - l_parapet += edge[:length] if edge[:type] == :parapetconvex - l_corner += edge[:length] if edge[:type] == :cornerconcave - l_corner += edge[:length] if edge[:type] == :cornerconvex - end + o_ud_md_en = TBD.ua_md(o_ua, :en) + path1 = File.join(__dir__, "files/ua/o_ua_en.md") + File.open(path1, "w") { |file| file.puts o_ud_md_en } - expect(l_fen ).to be_within(TOL).of( 0.00) - expect(l_head ).to be_within(TOL).of(12.81) - expect(l_sill ).to be_within(TOL).of(10.98) - expect(l_jamb ).to be_within(TOL).of(22.56) - expect(l_grade ).to be_within(TOL).of(27.69) - expect(l_parapet).to be_within(TOL).of(27.69) - expect(l_corner ).to be_within(TOL).of( 6.10) - end - end + # Alternate output UA' MD file. + argh2[:ua_ref ] = "code (Quebec)" + argh2[:io][:description] = "testing equality" + argh2[:version ] = OpenStudio.openStudioVersion + argh2[:seed ] = File.join(__dir__, "files/osms/in/warehouse.osm") - it "can process DOE prototype smalloffice.osm (hardset)" do - translator = OpenStudio::OSVersion::VersionTranslator.new + alt_ua = TBD.ua_summary(time, argh2) + expect(alt_ua).to_not be_nil + expect(alt_ua).to_not be_empty + expect(alt_ua).to be_a(Hash) + expect(alt_ua).to have_key(:model) + + alt_ud_md_en = TBD.ua_md(alt_ua, :en) + path2 = File.join(__dir__, "files/ua/alt_ua_en.md") + File.open(path2, "w") { |file| file.puts alt_ud_md_en } + + # Both output UA' MD files should be identical. + expect(TBD.status).to be_zero + expect(TBD.logs).to be_empty + expect(FileUtils).to be_identical(path1, path2) + + # Testing the Macumber suggestion (thumbs' up). TBD.clean! - file = File.join(__dir__, "files/osms/in/smalloffice.osm") + 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 - # In the preceding test, attic floor surfaces inherit constructions from - # adjacent office ceiling surfaces below. In this variant, attic floors - # adjacent to NSEW perimeter office ceilings have hardset constructions - # assigned to them (inverted). Results should remain the same as above. - model.getSurfaces.each do |s| - expect(s.space).to_not be_empty - next unless s.space.get.nameString == "Attic" - next unless s.nameString.include?("_perimeter") - - expect(s.surfaceType).to eq("Floor") - expect(s.isConstructionDefaulted).to be true - c = s.construction.get.to_LayeredConstruction - expect(c).to_not be_empty - c = c.get - expect(c.layers.size).to eq(2) - # layer[0]: "5/8 in. Gypsum Board" - # layer[1]: "Typical Insulation R-35.4 1" - - construction = c.clone(model).to_LayeredConstruction.get - expect(construction.handle.to_s).to_not be_empty - expect(construction.nameString).to_not be_empty - - str = "Typical Wood Joist Attic Floor R-37.04 2" - expect(construction.nameString).to eq(str) - construction.setName("#{s.nameString} floor") - expect(construction.layers.size).to eq(2) - expect(s.setConstruction(construction)).to be true - expect(s.isConstructionDefaulted).to be false - end + mdl2 = OpenStudio::Model::Model.new + mdl2.addObjects(model.toIdfFile.objects) + fil2 = File.join(__dir__, "files/osms/out/alt2_warehouse.osm") + mdl2.save(fil2, true) - argh = { option: "poor (BETBG)" } + # Still get the differences in handles (not consequential at all if the TBD + # JSON output files are identical). + expect(FileUtils).to_not be_identical(file, fil2) - 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] + TBD.process(mdl2, argh3) expect(TBD.status).to be_zero expect(TBD.logs).to be_empty - expect(surfaces).to be_a(Hash) - expect(surfaces.size).to eq(43) - expect(io).to be_a(Hash) - expect(io).to have_key(:edges) - expect(io[:edges].size).to eq(105) - # Testing attic surfaces. - surfaces.each do |id, surface| - expect(surface).to have_key(:space) - next unless surface[:space].nameString == "Attic" + expect(argh3).to have_key(:surfaces) + expect(argh3).to have_key(:io) - # Attic is an UNENCLOSED zone - outdoor-facing surfaces are not derated. - expect(surface).to have_key(:filmRSI) - expect(surface).to have_key(:conditioned) - expect(surface[:conditioned]).to be false - expect(surface).to_not have_key(:heatloss) - expect(surface).to_not have_key(:ratio) + expect(argh3[:surfaces]).to be_a(Hash) + expect(argh3[:surfaces].size).to eq(23) - expect(surface).to have_key(:boundary) - b = surface[:boundary] - next if b == "outdoors" + expect(argh3[:io]).to be_a(Hash) + expect(argh3[:io]).to have_key(:edges) + expect(argh3[:io][:edges].size).to eq(300) - expect(surfaces).to have_key(b) - expect(surfaces[b]).to have_key(:conditioned) - expect(surfaces[b][:conditioned]).to be true - next if id == "Attic_floor_core" + out3 = JSON.pretty_generate(argh3[:io]) + outP3 = File.join(__dir__, "../json/tbd_warehouse14.out.json") + File.open(outP3, "w") { |outP3| outP3.puts out3 } - expect(surfaces[b]).to have_key(:ratio) - expect(surfaces[b]).to have_key(:heatloss) - h = surfaces[b][:heatloss] + # Nice. Both TBD JSON output files are identical! + # "/../json/tbd_warehouse12.out.json" vs "/../json/tbd_warehouse14.out.json" + expect(FileUtils).to be_identical(outP, outP3) + end - # Derated constructions? - s = model.getSurfaceByName(id) - expect(s).to_not be_empty - s = s.get - expect(s.surfaceType).to eq("Floor") - expect(s.isConstructionDefaulted).to be false - c = s.construction.get.to_LayeredConstruction - expect(c).to_not be_empty - c = c.get - next unless c.nameString.include?("Attic_floor_perimeter_south") + it "can generate and access KIVA inputs (seb)" do + translator = OpenStudio::OSVersion::VersionTranslator.new + TBD.clean! - expect(c.nameString).to include("c tbd") - expect(c.layers.size).to eq(2) - expect(c.layers[0].nameString).to eq("5/8 in. Gypsum Board") - expect(c.layers[1].nameString).to include("m tbd") - expect(c.layers[1].to_MasslessOpaqueMaterial).to_not be_empty - m = c.layers[1].to_MasslessOpaqueMaterial.get - expect(surface[:filmRSI].round(4)).to eq(0.2665) + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + file = File.join(__dir__, "files/osms/out/seb2.osm") + path = OpenStudio::Path.new(file) + model = translator.loadModel(path) + expect(model).to_not be_empty + model = model.get - # Before derating. - # "5/8 in. Gypsum Board" : RSi = 0.0994 m2.K/W - # "Typical Insulation R-35.4 1" : RSi = 6.2348 m2.K/W - # surface air film resistances : RSi = 0.2665 m2.K/W - # ----------------------------- ------------------- - # RSi = 6.6007 m2.K/W - initial_R = surface[:filmRSI] - initial_R += 0.0994 - initial_R += 6.2348 - expect(initial_R.round(3)).to eq(6.601) + # Fetch all 5 outdoor-facing walls of the Open Area space. + oa13ID = "Openarea 1 Wall 3" + oa14ID = "Openarea 1 Wall 4" + oa15ID = "Openarea 1 Wall 5" + oa16ID = "Openarea 1 Wall 6" + oa17ID = "Openarea 1 Wall 7" + oaIDs = [oa13ID, oa14ID, oa15ID, oa16ID, oa17ID] - # After derating. - derated_R = surface[:filmRSI] - derated_R += 0.0994 - derated_R += m.thermalResistance - expect(derated_R.round(3)).to eq(3.319) + oa13 = model.getSurfaceByName(oa13ID) + oa14 = model.getSurfaceByName(oa14ID) + oa15 = model.getSurfaceByName(oa15ID) + oa16 = model.getSurfaceByName(oa16ID) + oa17 = model.getSurfaceByName(oa17ID) + expect(oa13).to_not be_empty + expect(oa14).to_not be_empty + expect(oa15).to_not be_empty + expect(oa16).to_not be_empty + expect(oa17).to_not be_empty + oa13 = oa13.get + oa14 = oa14.get + oa15 = oa15.get + oa16 = oa16.get + oa17 = oa17.get - ratio = -(initial_R - derated_R) * 100 / initial_R - expect(ratio.round(2)).to eq(-49.72) - expect(ratio).to be_within(1).of(surfaces[b][:ratio]) - end + woa13 = TBD.alignedWidth(oa13) + woa14 = TBD.alignedWidth(oa14) + woa15 = TBD.alignedWidth(oa15) + woa16 = TBD.alignedWidth(oa16) + woa17 = TBD.alignedWidth(oa17) + expect(woa13.round(2)).to eq(2.29) + expect(woa14.round(2)).to eq(2.14) + expect(woa15.round(2)).to eq(3.89) + expect(woa16.round(2)).to eq(2.45) + expect(woa17.round(2)).to eq(1.82) - surfaces.each do |id, surface| - next unless surface.key?(:edges) + # Assert 'exposed perimeter' of the Open Area space. + exp = woa13 + woa14 + woa15 + woa16 + woa17 + expect(exp.round(2)).to eq(12.59) - expect(surface).to have_key(:heatloss) + # For continuous insulation and/or finishings, OpenStudio/EnergyPlus/Kiva + # offer 2x solutions : + # + # 1. Add standard - not massless - materials as new construction layers + # 2. Add Kiva custom blocks + # + # ... sticking with Option #1. A few examples: - if id == "Core_ZN_ceiling" - expect(surface[:heatloss]).to be_within(0.001).of(0) - expect(surface).to_not have_key(:ratio) - expect(surface).to have_key(:u) - expect(surface[:u]).to be_within(0.001).of(0.152) - next - end + # Generic 1-1/2" XPS insulation. + xps_38mm = OpenStudio::Model::StandardOpaqueMaterial.new(model) + xps_38mm.setName("XPS_38mm") + xps_38mm.setRoughness("Rough") + xps_38mm.setThickness(0.0381) + xps_38mm.setConductivity(0.029) + xps_38mm.setDensity(28) + xps_38mm.setSpecificHeat(1450) + xps_38mm.setThermalAbsorptance(0.9) + xps_38mm.setSolarAbsorptance(0.7) - expect(surface).to have_key(:ratio) - h = surface[:heatloss] - s = model.getSurfaceByName(id) - expect(s).to_not be_empty - s = s.get - expect(s.nameString).to eq(id) - expect(s.isConstructionDefaulted).to be false - expect(s.construction.get.nameString).to include(" tbd") - next unless s.surfaceType == "Wall" + # 1. Current code-compliant slab-on-grade (perimeter) solution. + kiva_slab_2020s = OpenStudio::Model::FoundationKiva.new(model) + kiva_slab_2020s.setName("Kiva slab 2020s") + kiva_slab_2020s.setInteriorHorizontalInsulationMaterial(xps_38mm) + kiva_slab_2020s.setInteriorHorizontalInsulationWidth(1.2) + kiva_slab_2020s.setInteriorVerticalInsulationMaterial(xps_38mm) + kiva_slab_2020s.setInteriorVerticalInsulationDepth(0.138) - expect(h).to be_within(TOL).of(51.17) if id.include?("_1_") # South - expect(h).to be_within(TOL).of(33.08) if id.include?("_2_") # East - expect(h).to be_within(TOL).of(48.32) if id.include?("_3_") # North - expect(h).to be_within(TOL).of(33.08) if id.include?("_4_") # West + # 2. Beyond-code slab-on-grade (continuous) insulation setup. Add 1-1/2" + # XPS insulation layer (under slab) to surface construction. + kiva_slab_HP = OpenStudio::Model::FoundationKiva.new(model) + kiva_slab_HP.setName("Kiva slab HP") - 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.layers.size).to eq(4) - expect(c.layers[2].nameString).to include("m tbd") - next unless id.include?("_1_") # South + # 3. Do the same for (full height) basements - no insulation under slab for + # vintages 1980s & 2020s. Add (full-height) layered insulation and/or + # finishing to basement wall construction. + kiva_basement = OpenStudio::Model::FoundationKiva.new(model) + kiva_basement.setName("Kiva basement") - l_fen = 0 - l_head = 0 - l_sill = 0 - l_jamb = 0 - l_grade = 0 - l_parapet = 0 - l_corner = 0 + # 4. Beyond-code basement slab (perimeter) insulation setup. Add + # (full-height)layered insulation and/or finishing to basement wall + # construction. + kiva_basement_HP = OpenStudio::Model::FoundationKiva.new(model) + kiva_basement_HP.setName("Kiva basement HP") + kiva_basement_HP.setInteriorHorizontalInsulationMaterial(xps_38mm) + kiva_basement_HP.setInteriorHorizontalInsulationWidth(1.2) + kiva_basement_HP.setInteriorVerticalInsulationMaterial(xps_38mm) + kiva_basement_HP.setInteriorVerticalInsulationDepth(0.138) - surface[:edges].values.each do |edge| - l_fen += edge[:length] if edge[:type] == :fenestration - l_head += edge[:length] if edge[:type] == :head - l_sill += edge[:length] if edge[:type] == :sill - l_jamb += edge[:length] if edge[:type] == :jamb - l_grade += edge[:length] if edge[:type] == :grade - l_grade += edge[:length] if edge[:type] == :gradeconcave - l_grade += edge[:length] if edge[:type] == :gradeconvex - l_parapet += edge[:length] if edge[:type] == :parapet - l_parapet += edge[:length] if edge[:type] == :parapetconcave - l_parapet += edge[:length] if edge[:type] == :parapetconvex - l_corner += edge[:length] if edge[:type] == :cornerconcave - l_corner += edge[:length] if edge[:type] == :cornerconvex - end + # Set "Foundation" as boundary condition of 1x slab-on-grade, and link it + # to 1x Kiva Foundation object. + oa1f = model.getSurfaceByName("Open area 1 Floor") + expect(oa1f).to_not be_empty + oa1f = oa1f.get - expect(l_fen ).to be_within(TOL).of( 0.00) - expect(l_head ).to be_within(TOL).of(12.81) - expect(l_sill ).to be_within(TOL).of(10.98) - expect(l_jamb ).to be_within(TOL).of(22.56) - expect(l_grade ).to be_within(TOL).of(27.69) - expect(l_parapet).to be_within(TOL).of(27.69) - expect(l_corner ).to be_within(TOL).of( 6.10) - end - end + expect(oa1f.setOutsideBoundaryCondition("Foundation")).to be true + oa1f.setAdjacentFoundation(kiva_slab_2020s) + construction = oa1f.construction + expect(construction).to_not be_empty + construction = construction.get + expect(oa1f.setConstruction(construction)).to be true - it "can process DOE Prototype warehouse.osm" do - translator = OpenStudio::OSVersion::VersionTranslator.new - TBD.clean! + arg = "TotalExposedPerimeter" + per = oa1f.createSurfacePropertyExposedFoundationPerimeter(arg, exp) + expect(per).to_not be_empty - file = File.join(__dir__, "files/osms/in/warehouse.osm") + file = File.join(__dir__, "files/osms/out/seb_KIVA.osm") + model.save(file, true) + + + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + # Re-open for testing. path = OpenStudio::Path.new(file) model = translator.loadModel(path) expect(model).to_not be_empty model = model.get - model.getSurfaces.each do |s| - next unless s.outsideBoundaryCondition.downcase == "outdoors" + oa1f = model.getSurfaceByName("Open area 1 Floor") + expect(oa1f).to_not be_empty + oa1f = oa1f.get - expect(s.space).to_not be_empty - expect(s.isConstructionDefaulted).to be true - c = s.construction - expect(c).to_not be_empty - c = c.get.to_LayeredConstruction - expect(c).to_not be_empty - c = c.get + expect(oa1f.outsideBoundaryCondition.downcase).to eq("foundation") + foundation = oa1f.adjacentFoundation + expect(foundation).to_not be_empty + foundation = foundation.get - id = c.nameString - name = s.nameString - expect(c.layers[1].to_MasslessOpaqueMaterial).to_not be_empty + oa15 = model.getSurfaceByName(oa15ID) + expect(oa15).to_not be_empty + oa15 = oa15.get - m = c.layers[1].to_MasslessOpaqueMaterial.get - r = m.thermalResistance + construction = oa15.construction.get + expect(oa15.setOutsideBoundaryCondition("Foundation")).to be true + expect(oa15.setAdjacentFoundation(foundation)).to be true + expect(oa15.setConstruction(construction)).to be true - if name.include?("Bulk") - expect(r).to be_within(TOL).of(1.33) if id.include?("Wall") - expect(r).to be_within(TOL).of(1.68) if id.include?("Roof") - else - expect(r).to be_within(TOL).of(1.87) if id.include?("Wall") - expect(r).to be_within(TOL).of(3.06) if id.include?("Roof") - end - end + kfs = model.getFoundationKivas + expect(kfs).to_not be_empty + expect(kfs.size).to eq(4) + expect(model.foundationKivaSettings).to be_empty - argh = { option: "poor (BETBG)" } + argh = {} + argh[:option ] = "poor (BETBG)" + argh[:gen_kiva] = true json = TBD.process(model, argh) expect(json).to be_a(Hash) @@ -1301,116 +1196,198 @@ expect(json).to have_key(:surfaces) io = json[:io ] surfaces = json[:surfaces] - expect(TBD.status).to be_zero - expect(TBD.logs).to be_empty + expect(TBD.error?).to be true + expect(TBD.logs.size).to eq(1) + expect(TBD.logs.first[:message]).to include("Exiting - KIVA objects in ") expect(surfaces).to be_a(Hash) - expect(surfaces.size).to eq(23) + expect(surfaces.size).to eq(56) expect(io).to be_a(Hash) expect(io).to have_key(:edges) - expect(io[:edges].size).to eq(300) - ids = { a: "Office Front Wall", - b: "Office Left Wall", - c: "Fine Storage Roof", - d: "Fine Storage Office Front Wall", - e: "Fine Storage Office Left Wall", - f: "Fine Storage Front Wall", - g: "Fine Storage Left Wall", - h: "Fine Storage Right Wall", - i: "Bulk Storage Roof", - j: "Bulk Storage Rear Wall", - k: "Bulk Storage Left Wall", - l: "Bulk Storage Right Wall" - }.freeze + # 105x edges (-1x than the usual 106x for the seb2.osm). The edge linking + # "Open area 1 Floor" to "Openarea 1 Wall 5" used to be of type :grade. As + # both slab and wall are now ground-facing, TBD ignores the edge altogether. + expect(io[:edges].size).to eq(105) + expect(model.foundationKivaSettings).to be_empty + expect(model.getSurfacePropertyExposedFoundationPerimeters.size).to eq(1) + expect(model.getFoundationKivas.size).to eq(4) - # Testing. - surfaces.each do |id, surface| - expect(ids).to have_value(id) if surface.key?(:edges) - expect(ids).to_not have_value(id) unless surface.key?(:edges) - end + # TBD derates (above-grade) surfaces as usual. TBD is certainly 'aware' of + # the "Foundation"-facing slab and wall (and their shared edge), yet exits + # the KIVA generation step. As the warning message suggests, TBD safely + # exits when the OpenStudio model already holds KIVA objects. + surfaces.values.each { |surface| expect(surface).to_not have_key(:kiva) } - surfaces.each do |id, surface| - next unless surface.key?(:edges) + # As with the previously altered "files/osms/out/seb_KIVA.osm", OpenStudio + # can forward-translate and run an EnergyPlus simulation without warnings or + # errors. As "Openarea 1 Wall 5" is now a "Foundation"-facing wall, the + # exposed foundation perimeter length (set previously) is now invalid. Yet + # there are no internal checks in OpenStudio and/or EnergyPlus to ensure + # perimeter length consistency, WHEN exposed + foundation perimeter + # lengths < total slab perimeter lengths. Simulation runs without a glitch; + # simulation results would be 'off'. + file = File.join(__dir__, "files/osms/out/seb_KIVA2.osm") + model.save(file, true) - expect(ids).to have_value(id) - expect(surface).to have_key(:heatloss) - expect(surface).to have_key(:ratio) - h = surface[:heatloss] - s = model.getSurfaceByName(id) - expect(s).to_not be_empty - s = s.get - expect(s.nameString).to eq(id) - expect(s.isConstructionDefaulted).to be false - expect(s.construction.get.nameString).to include(" tbd") - expect(h).to be_within(TOL).of( 50.20) if id == ids[:a] - expect(h).to be_within(TOL).of( 24.06) if id == ids[:b] - expect(h).to be_within(TOL).of( 87.16) if id == ids[:c] - expect(h).to be_within(TOL).of( 22.61) if id == ids[:d] - expect(h).to be_within(TOL).of( 9.15) if id == ids[:e] - expect(h).to be_within(TOL).of( 26.47) if id == ids[:f] - expect(h).to be_within(TOL).of( 27.19) if id == ids[:g] - expect(h).to be_within(TOL).of( 41.36) if id == ids[:h] - expect(h).to be_within(TOL).of(161.02) if id == ids[:i] - expect(h).to be_within(TOL).of( 62.28) if id == ids[:j] - expect(h).to be_within(TOL).of(117.87) if id == ids[:k] - expect(h).to be_within(TOL).of( 95.77) if id == ids[:l] - 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.layers[1].nameString).to include("m tbd") - end + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + # Try again, yet by first purging existing KIVA objects in the model. + TBD.clean! + file = File.join(__dir__, "files/osms/out/seb_KIVA.osm") + path = OpenStudio::Path.new(file) + model = translator.loadModel(path) + expect(model).to_not be_empty + model = model.get - surfaces.each do |id, surface| - if surface.key?(:ratio) - # ratio = format "%3.1f", surface[:ratio] - # name = id.rjust(15, " ") - # puts "#{name} RSi derated by #{ratio}%" - expect(surface[:ratio]).to be_within(0.2).of(-53.0) if id == ids[:b] - expect(surface[:ratio]).to be_within(0.2).of(-15.6) if id == ids[:c] - expect(surface[:ratio]).to be_within(0.2).of(- 7.3) if id == ids[:i] - else - expect(surface[:boundary]).to_not eq("outdoors") + kfs = model.getFoundationKivas + expect(kfs).to_not be_empty + expect(kfs.size).to eq(4) + expect(model.foundationKivaSettings).to be_empty + + oa1f = model.getSurfaceByName("Open area 1 Floor") + expect(oa1f).to_not be_empty + oa1f = oa1f.get + + expect(oa1f.outsideBoundaryCondition.downcase).to eq("foundation") + foundation = oa1f.adjacentFoundation + expect(foundation).to_not be_empty + + srfIDs = ["Open area 1 Floor"] + + # Incrementally change Open Area outdoor-facing walls to foundation-facing, + # and ensure KIVA reset works. Exposed perimeter should remain the same. + oaIDs.each_with_index do |oaID, i| + i3 = i + 3 + + oa1f = model.getSurfaceByName("Open area 1 Floor") + expect(oa1f).to_not be_empty + oa1f = oa1f.get + + expect(oa1f.outsideBoundaryCondition.downcase).to eq("foundation") + foundation = oa1f.adjacentFoundation + expect(foundation).to_not be_empty + + oaWALL = model.getSurfaceByName(oaID) + expect(oaWALL).to_not be_empty + oaWALL = oaWALL.get + + construction = oaWALL.construction.get + expect(oaWALL.outsideBoundaryCondition.downcase).to eq("outdoors") + expect(oaWALL.setOutsideBoundaryCondition("Foundation")).to be true + expect(oaWALL.setConstruction(construction)).to be true + + srfIDs << oaID + + argh = {} + argh[:option ] = "(non thermal bridging)" + argh[:gen_kiva ] = true + argh[:reset_kiva] = true + + 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.info?).to be true + expect(TBD.logs.size).to eq(i + 1) + + TBD.logs.each do |lg| + expect(lg[:message]).to include("Purged KIVA objects from ") + end + + expect(surfaces).to be_a(Hash) + expect(surfaces.size).to eq(56) + expect(io).to be_a(Hash) + expect(io).to have_key(:edges) + expect(io[:edges].size).to eq(105 - 2 * i) + expect(model.foundationKivaSettings).to_not be_empty + expect(model.getSurfacePropertyExposedFoundationPerimeters.size).to eq(1) + expect(model.getFoundationKivas.size).to eq(1) # !4 ... previously purged + + perimeter = model.getSurfacePropertyExposedFoundationPerimeters.first + expect(perimeter.totalExposedPerimeter).to_not be_empty + expect(perimeter.totalExposedPerimeter.get.round(2)).to eq(exp.round(2)) + + # By default, KIVA foundation objects have a 200mm 'wall height above + # grade' value, i.e. a top, 8-in section exposed to outdoor air. This + # seems to generate the following EnergyPlus warning: + # + # ** Warning ** BuildingSurface:Detailed="OPENAREA 1 WALL 5", Sun Exposure="SUNEXPOSED". + # ** ~~~ ** ..This surface is not exposed to External Environment. Sun exposure has no effect. + # + # Initial attempts to get rid of the warning include resetting both wind + # and sun exposure AFTER setting boundary conditions to "Foundation", e.g. + # + # expect(wall.setOutsideBoundaryCondition("Foundation")).to be true + # expect(wall.setWindExposure("NoWind")).to be true + # expect(wall.setSunExposure("NoSun")).to be true + # + # Alas, both "exposures" end up being reset in the saved OSM. One solution + # is to first set the 'wall height above grade' value to 0. Works. + kf = model.getFoundationKivas.first + expect(kf.isWallHeightAboveGradeDefaulted).to be true + expect(kf.wallHeightAboveGrade.round(1)).to eq(0.2) + expect(kf.setWallHeightAboveGrade(0)).to be true + expect(kf.isWallHeightAboveGradeDefaulted).to be false + expect(kf.wallHeightAboveGrade.round).to eq(0) + + ewalls = TBD.facets(model.getSpaces, "foundation", "wall") + expect(ewalls.size).to eq(i + 1) + + ewalls.each do |wall| + expect(wall.setWindExposure("NoWind")).to be true + expect(wall.setSunExposure("NoSun")).to be true + end + + found_floor = false + found_walls = false + + surfaces.each do |id, surface| + next unless surface.key?(:kiva) + + expect(srfIDs).to include(id) + + if id == "Open area 1 Floor" + expect(surface[:kiva]).to eq(:basement) + expect(surface).to have_key(:exposed) + expect(surface[:exposed]).to be_within(TOL).of(exp) + found_floor = true + else + expect(surface[:kiva]).to eq("Open area 1 Floor") + found_walls = true + end end + + expect(found_floor).to be true + expect(found_walls).to be true end - end - it "can process DOE Prototype warehouse.osm + JSON I/O" do - translator = OpenStudio::OSVersion::VersionTranslator.new - TBD.clean! + file = File.join(__dir__, "files/osms/out/seb_KIVA3.osm") + model.save(file, true) - file = File.join(__dir__, "files/osms/in/warehouse.osm") + + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + # Test initial model again. + TBD.clean! + file = File.join(__dir__, "files/osms/out/seb2.osm") path = OpenStudio::Path.new(file) model = translator.loadModel(path) expect(model).to_not be_empty model = model.get - # Run the measure with a basic TBD JSON input file, e.g. - # - a custom PSI set, e.g. "compliant" set - # - (4x) custom edges, e.g. "bad" :fenestration perimeters between - # - "Office Left Wall Window1" & "Office Left Wall" - # - # The TBD JSON input file should hold the following: - # - # "edges": [ - # { - # "psi": "bad", - # "type": "fenestration", - # "surfaces": [ - # "Office Left Wall Window1", - # "Office Left Wall" - # ] - # } - # ], + # Add "Foundation" as outside boundary condition to slabs, WITHOUT adding + # any other KIVA-related objects. + model.getSurfaces.each do |s| + next unless s.isGroundSurface + next unless s.surfaceType.downcase == "floor" - # Despite defining the PSI set as having no thermal bridges, the "compliant" - # PSI set on file will be considered as the building-wide default set. - argh = {} - argh[:option ] = "(non thermal bridging)" - argh[:io_path ] = File.join(__dir__, "../json/tbd_warehouse.json") - argh[:schema_path] = File.join(__dir__, "../tbd.schema.json") + expect(s.setOutsideBoundaryCondition("Foundation")).to be true + end + + argh = {} + argh[:option ] = "(non thermal bridging)" + argh[:gen_kiva] = true json = TBD.process(model, argh) expect(json).to be_a(Hash) @@ -1421,195 +1398,108 @@ expect(TBD.status).to be_zero expect(TBD.logs).to be_empty expect(surfaces).to be_a(Hash) - expect(surfaces.size).to eq(23) + expect(surfaces.size).to eq(56) expect(io).to be_a(Hash) expect(io).to have_key(:edges) - expect(io[:edges].size).to eq(300) + expect(io[:edges].size).to eq(106) - ids = { a: "Office Front Wall", - b: "Office Left Wall", - c: "Fine Storage Roof", - d: "Fine Storage Office Front Wall", - e: "Fine Storage Office Left Wall", - f: "Fine Storage Front Wall", - g: "Fine Storage Left Wall", - h: "Fine Storage Right Wall", - i: "Bulk Storage Roof", - j: "Bulk Storage Rear Wall", - k: "Bulk Storage Left Wall", - l: "Bulk Storage Right Wall" - }.freeze + slabs = 0 - surfaces.each do |id, surface| - expect(ids).to have_value(id) if surface.key?(:edges) - expect(ids).to_not have_value(id) unless surface.key?(:edges) - end + surfaces.each do |id, s| + next unless s.key?(:kiva) - surfaces.each do |id, surface| - next unless surface.key?(:edges) + slabs += 1 + expect(s).to have_key(:exposed) + slab = model.getSurfaceByName(id) + expect(slab).to_not be_empty + slab = slab.get - expect(ids).to have_value(id) - expect(surface).to have_key(:ratio) - expect(surface).to have_key(:heatloss) - h = surface[:heatloss] - s = model.getSurfaceByName(id) - expect(s).to_not be_empty - s = s.get - expect(s.nameString).to eq(id) - expect(s.isConstructionDefaulted).to be false - expect(s.construction.get.nameString).to include(" tbd") - expect(h).to be_within(TOL).of( 25.90) if id == ids[:a] - expect(h).to be_within(TOL).of( 17.41) if id == ids[:b] # 13.38 compliant - expect(h).to be_within(TOL).of( 45.44) if id == ids[:c] - expect(h).to be_within(TOL).of( 8.04) if id == ids[:d] - expect(h).to be_within(TOL).of( 3.46) if id == ids[:e] - expect(h).to be_within(TOL).of( 13.27) if id == ids[:f] - expect(h).to be_within(TOL).of( 14.04) if id == ids[:g] - expect(h).to be_within(TOL).of( 21.20) if id == ids[:h] - expect(h).to be_within(TOL).of( 88.34) if id == ids[:i] - expect(h).to be_within(TOL).of( 30.98) if id == ids[:j] - expect(h).to be_within(TOL).of( 64.44) if id == ids[:k] - expect(h).to be_within(TOL).of( 48.97) if id == ids[:l] + expect(slab.adjacentFoundation).to_not be_empty + perimeter = slab.surfacePropertyExposedFoundationPerimeter + expect(perimeter).to_not be_empty + perimeter = perimeter.get - 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.layers[1].nameString).to include("m tbd") - end + per = perimeter.totalExposedPerimeter + expect(per).to_not be_empty + per = per.get + expect((per - s[:exposed]).abs).to be_within(TOL).of(0) - surfaces.each do |id, surface| - if surface.key?(:ratio) - # ratio = format "%3.1f", surface[:ratio] - # name = id.rjust(15, " ") - # puts "#{name} RSi derated by #{ratio}%" - expect(surface[:ratio]).to be_within(0.2).of(-46.0) if id == ids[:b] - else - expect(surface[:boundary]).to_not eq("outdoors") - end + expect(per).to be_within(TOL).of( 8.81) if id == "Small office 1 Floor" + expect(per).to be_within(TOL).of( 8.21) if id == "Utility 1 Floor" + expect(per).to be_within(TOL).of(12.59) if id == "Open area 1 Floor" + expect(per).to be_within(TOL).of( 6.95) if id == "Entry way Floor" end - # Now mimic the export functionality of the measure. - out = JSON.pretty_generate(io) - outP = File.join(__dir__, "../json/tbd_warehouse.out.json") - File.open(outP, "w") { |outP| outP.puts out } + expect(slabs).to eq(4) - # 2. Re-use the exported file as input for another warehouse. - model2 = translator.loadModel(path) - expect(model2).to_not be_empty - model2 = model2.get + file = File.join(__dir__, "files/osms/out/seb_KIVA4.osm") + model.save(file, true) - argh[:io_path] = File.join(__dir__, "../json/tbd_warehouse.out.json") - json2 = TBD.process(model2, argh) - expect(json2).to be_a(Hash) - expect(json2).to have_key(:io) - expect(json2).to have_key(:surfaces) - io2 = json2[:io ] - surfaces = json2[:surfaces] - expect(TBD.status).to be_zero - expect(TBD.logs).to be_empty + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + # Recover KIVA-populated model and re- set/gen KIVA. + argh = {} + argh[:option ] = "(non thermal bridging)" + argh[:gen_kiva ] = true + argh[:reset_kiva] = true + + 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.info?).to be true + expect(TBD.logs.size).to eq(1) + expect(TBD.logs.first[:message]).to include("Purged KIVA objects from ") expect(surfaces).to be_a(Hash) - expect(surfaces.size).to eq(23) + expect(surfaces.size).to eq(56) expect(io).to be_a(Hash) expect(io).to have_key(:edges) - expect(io[:edges].size).to eq(300) + expect(io[:edges].size).to eq(106) - # Testing (again). - surfaces.each do |id, surface| - next unless surface.key?(:edges) + slabs = 0 - expect(surface).to have_key(:ratio) - expect(surface).to have_key(:heatloss) - h = surface[:heatloss] - s = model.getSurfaceByName(id) - expect(s).to_not be_empty - s = s.get - expect(s.nameString).to eq(id) - expect(s.isConstructionDefaulted).to be false - expect(s.construction.get.nameString).to include(" tbd") - expect(h).to be_within(TOL).of( 25.90) if id == ids[:a] - expect(h).to be_within(TOL).of( 17.41) if id == ids[:b] - expect(h).to be_within(TOL).of( 45.44) if id == ids[:c] - expect(h).to be_within(TOL).of( 8.04) if id == ids[:d] - expect(h).to be_within(TOL).of( 3.46) if id == ids[:e] - expect(h).to be_within(TOL).of( 13.27) if id == ids[:f] - expect(h).to be_within(TOL).of( 14.04) if id == ids[:g] - expect(h).to be_within(TOL).of( 21.20) if id == ids[:h] - expect(h).to be_within(TOL).of( 88.34) if id == ids[:i] - expect(h).to be_within(TOL).of( 30.98) if id == ids[:j] - expect(h).to be_within(TOL).of( 64.44) if id == ids[:k] - expect(h).to be_within(TOL).of( 48.97) if id == ids[:l] + # Same outcome as "seb_KIVA4.osm". + surfaces.each do |id, s| + next unless s.key?(:kiva) - 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.layers[1].nameString).to include("m tbd") - end + slabs += 1 + expect(s).to have_key(:exposed) + slab = model.getSurfaceByName(id) + expect(slab).to_not be_empty + slab = slab.get - surfaces.each do |id, surface| - if surface.key?(:ratio) - # ratio = format "%3.1f", surface[:ratio] - # name = id.rjust(15, " ") - # puts "#{name} RSi derated by #{ratio}%" - expect(surface[:ratio]).to be_within(0.2).of(-46.0) if id == ids[:b] - else - expect(surface[:boundary]).to_not eq("outdoors") - end - end + expect(slab.adjacentFoundation).to_not be_empty + perimeter = slab.surfacePropertyExposedFoundationPerimeter + expect(perimeter).to_not be_empty + perimeter = perimeter.get - # Now mimic (again) the export functionality of the measure. Both output - # files should be the same. - out2 = JSON.pretty_generate(io2) - outP2 = File.join(__dir__, "../json/tbd_warehouse2.out.json") - File.open(outP2, "w") { |outP2| outP2.puts out2 } - expect(FileUtils).to be_identical(outP, outP2) - end + per = perimeter.totalExposedPerimeter + expect(per).to_not be_empty + per = per.get + expect((per - s[:exposed]).abs).to be_within(TOL).of(0) - it "can process DOE Prototype warehouse.osm + JSON I/O (2)" do - translator = OpenStudio::OSVersion::VersionTranslator.new - TBD.clean! + expect(per).to be_within(TOL).of( 8.81) if id == "Small office 1 Floor" + expect(per).to be_within(TOL).of( 8.21) if id == "Utility 1 Floor" + expect(per).to be_within(TOL).of(12.59) if id == "Open area 1 Floor" + expect(per).to be_within(TOL).of( 6.95) if id == "Entry way Floor" + end - 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 + expect(slabs).to eq(4) - # Run the measure with a basic TBD JSON input file, e.g. - # - a custom PSI set, e.g. "compliant" set - # - (1x) custom edges, e.g. "bad" :fenestration perimeters between - # - "Office Left Wall Window1" & "Office Left Wall" - # - 1x? this time, with explicit 3D coordinates for shared edge. - # - # The TBD JSON input file should hold the following: - # - # "edges": [ - # { - # "psi": "bad", - # "type": "fen", - # "surfaces": [ - # "Office Left Wall Window1", - # "Office Left Wall" - # ], - # "v0x": 0.0, - # "v0y": 7.51904930207155, - # "v0z": 0.914355407629293, - # "v1x": 0.0, - # "v1y": 5.38555335093654, - # "v1z": 0.914355407629293 - # } - # ], + # Forward-translating/running either "seb_KIVA4.osm" or "seb_KIVA5.osm" + # would yield the same simulation results. + file = File.join(__dir__, "files/osms/out/seb_KIVA5.osm") + model.save(file, true) - # Despite defining the PSI set as having no thermal bridges, the "compliant" - # PSI set on file will be considered as the building-wide default set. - argh = {} - argh[:option ] = "(non thermal bridging)" - argh[:io_path ] = File.join(__dir__, "../json/tbd_warehouse1.json") - argh[:schema_path] = File.join(__dir__, "../tbd.schema.json") + + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + # Recover KIVA-populated model and re-gen KIVA ... WITHOUT resetting KIVA. + TBD.clean! + argh = {} + argh[:option ] = "(non thermal bridging)" + argh[:gen_kiva] = true json = TBD.process(model, argh) expect(json).to be_a(Hash) @@ -1617,137 +1507,37 @@ expect(json).to have_key(:surfaces) io = json[:io ] surfaces = json[:surfaces] - expect(TBD.status).to be_zero - expect(TBD.logs).to be_empty + expect(TBD.error?).to be true + expect(TBD.logs.size).to eq(1) + expect(TBD.logs.first[:message]).to include("Exiting - KIVA objects in ") expect(surfaces).to be_a(Hash) - expect(surfaces.size).to eq(23) + expect(surfaces.size).to eq(56) expect(io).to be_a(Hash) expect(io).to have_key(:edges) - expect(io[:edges].size).to eq(300) + expect(io[:edges].size).to eq(106) - ids = { a: "Office Front Wall", - b: "Office Left Wall", - c: "Fine Storage Roof", - d: "Fine Storage Office Front Wall", - e: "Fine Storage Office Left Wall", - f: "Fine Storage Front Wall", - g: "Fine Storage Left Wall", - h: "Fine Storage Right Wall", - i: "Bulk Storage Roof", - j: "Bulk Storage Rear Wall", - k: "Bulk Storage Left Wall", - l: "Bulk Storage Right Wall" }.freeze - - # Testing. - surfaces.each do |id, surface| - expect(ids).to have_value(id) if surface.key?(:edges) - expect(ids).to_not have_value(id) unless surface.key?(:edges) - end - - surfaces.each do |id, surface| - next unless surface.key?(:edges) - - expect(ids).to have_value(id) - expect(surface).to have_key(:ratio) - expect(surface).to have_key(:heatloss) - h = surface[:heatloss] - s = model.getSurfaceByName(id) - expect(s).to_not be_empty - s = s.get - expect(s.nameString).to eq(id) - expect(s.isConstructionDefaulted).to be false - expect(s.construction.get.nameString).to include(" tbd") - expect(h).to be_within(TOL).of( 25.90) if id == ids[:a] - expect(h).to be_within(TOL).of( 14.55) if id == ids[:b] # 13.4 compliant - expect(h).to be_within(TOL).of( 45.44) if id == ids[:c] - expect(h).to be_within(TOL).of( 8.04) if id == ids[:d] - expect(h).to be_within(TOL).of( 3.46) if id == ids[:e] - expect(h).to be_within(TOL).of( 13.27) if id == ids[:f] - expect(h).to be_within(TOL).of( 14.04) if id == ids[:g] - expect(h).to be_within(TOL).of( 21.20) if id == ids[:h] - expect(h).to be_within(TOL).of( 88.34) if id == ids[:i] - expect(h).to be_within(TOL).of( 30.98) if id == ids[:j] - expect(h).to be_within(TOL).of( 64.44) if id == ids[:k] - expect(h).to be_within(TOL).of( 48.97) if id == ids[:l] - - 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.layers[1].nameString).to include("m tbd") - end - - surfaces.each do |id, surface| - if surface.key?(:ratio) - # ratio = format "%3.1f", surface[:ratio] - # name = id.rjust(15, " ") - # puts "#{name} RSi derated by #{ratio}%" - expect(surface[:ratio]).to be_within(0.2).of(-41.9) if id == ids[:b] - else - expect(surface[:boundary]).to_not eq("outdoors") - end - end - - # Now mimic the export functionality of the measure - out = JSON.pretty_generate(io) - outP = File.join(__dir__, "../json/tbd_warehouse1.out.json") - File.open(outP, "w") { |outP| outP.puts out } - - # 2. Re-use the exported file as input for another warehouse - model2 = translator.loadModel(path) - expect(model2).to_not be_empty - model2 = model2.get - - argh[:io_path] = File.join(__dir__, "../json/tbd_warehouse1.out.json") - - json2 = TBD.process(model2, argh) - expect(json2).to be_a(Hash) - expect(json2).to have_key(:io) - expect(json2).to have_key(:surfaces) - io2 = json2[:io ] - surfaces = json2[:surfaces] - expect(TBD.status).to be_zero - expect(TBD.logs).to be_empty - expect(surfaces).to be_a(Hash) - expect(surfaces.size).to eq(23) - expect(io).to be_a(Hash) - expect(io).to have_key(:edges) - expect(io[:edges].size).to eq(300) - - surfaces.each do |id, surface| - if surface.key?(:ratio) - # ratio = format "%3.1f", surface[:ratio] - # name = id.rjust(15, " ") - # puts "#{name} RSi derated by #{ratio}%" - expect(surface[:ratio]).to be_within(0.2).of(-41.9) if id == ids[:b] - else - expect(surface[:boundary]).to_not eq("outdoors") - end - end + # Without a resetKIVA request, TBD exits with 1x error message. + surfaces.values.each { |surface| expect(surface).to_not have_key(:kiva) } - # Now mimic (again) the export functionality of the measure. Both output - # files should be the same. - out2 = JSON.pretty_generate(io2) - outP2 = File.join(__dir__, "../json/tbd_warehouse3.out.json") - File.open(outP2, "w") { |outP2| outP2.puts out2 } - expect(FileUtils).to be_identical(outP, outP2) + # As the initial model already has valid & complete KIVA inputs, one + # obtains the same outcome as "seb_KIVA4.osm" & "seb_KIVA5.osm". + file = File.join(__dir__, "files/osms/out/seb_KIVA6.osm") + model.save(file, true) end - it "can factor in spacetype-specific PSI sets (JSON input)" do + it "can generate and access KIVA inputs (midrise apts)" do translator = OpenStudio::OSVersion::VersionTranslator.new TBD.clean! - file = File.join(__dir__, "files/osms/in/warehouse.osm") + file = File.join(__dir__, "files/osms/in/midrise.osm") path = OpenStudio::Path.new(file) model = translator.loadModel(path) expect(model).to_not be_empty model = model.get - argh = {} - argh[:option ] = "compliant" # superseded by :building PSI set on file - argh[:io_path ] = File.join(__dir__, "../json/tbd_warehouse5.json") - argh[:schema_path] = File.join(__dir__, "../tbd.schema.json") + argh = {} + argh[:option ] = "poor (BETBG)" + argh[:gen_kiva] = true json = TBD.process(model, argh) expect(json).to be_a(Hash) @@ -1758,74 +1548,74 @@ expect(TBD.status).to be_zero expect(TBD.logs).to be_empty expect(surfaces).to be_a(Hash) - expect(surfaces.size).to eq(23) + expect(surfaces.size).to eq(180) expect(io).to be_a(Hash) - expect(io).to have_key(:spacetypes) expect(io).to have_key(:edges) - expect(io[:edges].size).to eq(300) - - sTyp1 = "Warehouse Office" - sTyp2 = "Warehouse Fine" - - io[:spacetypes].each do |spacetype| - expect(spacetype).to have_key(:id) - expect(spacetype[:id]).to eq(sTyp1).or eq(sTyp2) - expect(spacetype).to have_key(:psi) - end + expect(io[:edges].size).to eq(282) + # Validate. surfaces.each do |id, surface| - next unless surface[:boundary] == "outdoors" - next unless surface.key?(:ratio) - - expect(surface).to have_key(:heatloss) - heatloss = surface[:heatloss] - expect(heatloss.abs).to be > 0 - expect(surface).to have_key(:space) - next unless surface[:space].nameString == "Zone1 Office" + next unless surface.key?(:foundation) # ... only floors + next unless surface.key?(:kiva) - # All applicable thermal bridges/edges derating the office walls inherit - # the "Warehouse Office" spacetype PSI values (JSON file), except for the - # shared :rimjoist with the Fine Storage space above. The "Warehouse Fine" - # spacetype set has a higher :rimjoist PSI value of 0.5 W/K per metre, - # which overrides the "Warehouse Office" value of 0.3 W/K per metre. - expect(heatloss).to be_within(TOL).of(11.61) if id == "Office Left Wall" - expect(heatloss).to be_within(TOL).of(22.94) if id == "Office Front Wall" + expect(surface[:kiva]).to eq(:slab) + expect(surface).to have_key(:exposed) + expect(id).to eq("g Floor C") + expect(surface[:exposed]).to be_within(TOL).of(3.36) + gFC = model.getSurfaceByName("g Floor C") + expect(gFC).to_not be_empty + gFC = gFC.get + expect(gFC.outsideBoundaryCondition.downcase).to eq("foundation") end + + file = File.join(__dir__, "files/osms/out/midrise_KIVA2.osm") + model.save(file, true) end - it "can sort multiple story-specific PSI sets (JSON input)" do + it "can test 5ZoneNoHVAC (failed) uprating" do translator = OpenStudio::OSVersion::VersionTranslator.new TBD.clean! - file = File.join(__dir__, "files/osms/in/midrise.osm") + walls = [] + id = "ASHRAE 189.1-2009 ExtWall Mass ClimateZone 5" + file = File.join(__dir__, "files/osms/in/5ZoneNoHVAC.osm") path = OpenStudio::Path.new(file) model = translator.loadModel(path) expect(model).to_not be_empty model = model.get - model.getSpaces.each do |space| - expect(space.thermalZone).to_not be_empty - zone = space.thermalZone.get - stpts = TBD.setpoints(space) - expect(TBD.plenum?(space)).to be false - expect(stpts).to have_key(:heating) - expect(stpts).to have_key(:cooling) + # Get geometry data for testing (4x exterior walls, same construction). + construction = nil - if zone.nameString == "Office ZN" - expect(stpts[:heating]).to be_within(0.1).of(21.1) - expect(stpts[:cooling]).to be_within(0.1).of(23.9) - else - expect(stpts[:heating]).to be_within(0.1).of(21.7) - expect(stpts[:cooling]).to be_within(0.1).of(24.4) - end + model.getSurfaces.each do |s| + next unless s.surfaceType == "Wall" + next unless s.outsideBoundaryCondition == "Outdoors" + + walls << s.nameString + c = s.construction + expect(c).to_not be_empty + c = c.get.to_LayeredConstruction + expect(c).to_not be_empty + c = c.get + + construction = c if construction.nil? + expect(c).to eq(construction) end - argh = {} - argh[:option ] = "(non thermal bridging)" # overridden - argh[:io_path ] = File.join(__dir__, "../json/midrise.json") - argh[:schema_path] = File.join(__dir__, "../tbd.schema.json") + expect(walls.size ).to eq( 4) + expect(construction.nameString ).to eq(id) + expect(construction.layers.size).to eq( 4) - json = TBD.process(model, argh) + insulation = construction.layers[2].to_StandardOpaqueMaterial + expect(insulation).to_not be_empty + insulation = insulation.get + 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.round(4)).to eq(1.8380) + + argh = { option: "efficient (BETBG)" } # all PSI-factors @ 0.2 W/K•m + json = TBD.process(model, argh) expect(json).to be_a(Hash) expect(json).to have_key(:io) expect(json).to have_key(:surfaces) @@ -1833,816 +1623,682 @@ surfaces = json[:surfaces] expect(TBD.status).to be_zero expect(TBD.logs).to be_empty - expect(surfaces).to be_a(Hash) - expect(surfaces.size).to eq(180) - expect(io).to be_a(Hash) - expect(io).to have_key(:stories) - expect(io).to have_key(:edges) - expect(io[:edges].size).to eq(282) - - surfaces.each do |id, surface| - expect(surface).to have_key(:conditioned) - next unless surface[:conditioned] - - expect(surface).to have_key(:heating) - expect(surface).to have_key(:cooling) - end - - # A side test. Validating that TBD doesn't tag shared edge between exterior - # wall and interior ceiling (adiabatic conditions) as 'party' for - # 'multiplied' mid-level spaces. In fact, there shouldn't be a single - # instance of a 'party' edge in the TBD model. - surfaces.each do |id, surface| - next unless surface.key?(:ratio) - - expect(surface).to have_key(:edges) - - surface[:edges].values.each do |edge| - expect(edge).to have_key(:type) - expect(edge[:type]).to_not eq(:party) - end - end - expect(io[:stories].size).to eq(3) - stories = ["Building Story 1", "Building Story 2", "Building Story 3"] - types = [:parapetconvex, :transition] + walls.each do |wall| + expect(surfaces).to have_key(wall) + expect(surfaces[wall]).to have_key(:heatloss) - io[:stories].each do |story| - expect(story).to have_key(:psi) - expect(story).to have_key(:id) - expect(stories).to include(story[:id]) + long = (surfaces[wall][:heatloss] - 27.746).abs < TOL # 40 metres wide + short = (surfaces[wall][:heatloss] - 14.548).abs < TOL # 20 metres wide + expect(long || short).to be true end - counter = 0 - - surfaces.each do |id, surface| - next unless surface.key?(:ratio) - - expect(surface).to have_key(:story) - expect(surface).to have_key(:boundary) - expect(surface[:boundary]).to eq("outdoors") - - nom = surface[:story].nameString - expect(stories).to include(nom) - expect(nom).to eq(stories[0]) if id.include?("g ") - expect(nom).to eq(stories[1]) if id.include?("m ") - expect(nom).to eq(stories[2]) if id.include?("t ") - expect(surface).to have_key(:edges) - - counter += 1 - - # Illustrating that story-specific PSI set is used when only 1x story. - surface[:edges].values.each do |edge| - expect(edge).to have_key(:type) - expect(edge).to have_key(:psi) - next unless id.include?("Roof") - - expect(types).to include(edge[:type]) - next unless edge[:type] == :parapetconvex - next if id == "t Roof C" - - expect(edge[:psi]).to be_within(TOL).of(0.178) # 57.3% of 0.311 - end + # The 4-sided model has 2x "long" front/back + 2x "short" side exterior + # walls, with a total TBD-calculated heat loss (from thermal bridging) of: + # + # 2x 27.746 W/K + 2x 14.548 W/K = ~84.588 W/K + # + # Spread over ~273.6 m2 of gross wall area, that is A LOT! Why (given the + # "efficient" PSI-factors)? Each wall has a long "strip" window, almost the + # full wall width (reaching to within a few millimetres of each corner). + # This ~slices the host wall into 2x very narrow strips. Although the + # thermal bridging details are considered "efficient", the total length of + # linear thermal bridges is very high given the limited exposed (gross) + # area. If area-weighted, derating the insulation layer of the referenced + # wall construction above would entail factoring in this extra thermal + # conductance of ~0.309 W/m2•K (84.6/273.6), which would increase the + # insulation conductivity quite significantly. + # + # Ut = Uo + ( ∑psi • L )/A + # + # Expressed otherwise: + # + # Ut = Uo + 0.309 + # + # So what initial Uo factor should the construction offer (prior to + # derating) to ensure compliance with NECB2017/2020 prescriptive + # requirements (one of the few energy codes with prescriptive Ut + # requirements)? For climate zone 7, the target Ut is 0.210 W/m2•K (Rsi + # 4.76 m2•K/W or R27). Taking into account air film resistances and + # non-insulating layer resistances (e.g. ~Rsi 1 m2•K/W), the prescribed + # (max) layer Ut becomes ~0.277 (Rsi 3.6 or R20.5). + # + # 0.277 = Uo? + 0.309 + # + # Duh-oh! Even with an infinitely thick insulation layer (Uo ~= 0), it + # would be impossible to reach NECB2017/2020 prescritive requirements with + # "efficient" thermal breaks. Solutions? Eliminate windows :\ Otherwise, + # further improve detailing as to achieve ~0.1 W/K per linear metre + # (easier said than done). Here, an average PSI-factor of 0.150 W/K per + # linear metre (i.e. ~76.1 W/K instead of ~84.6 W/K) still won't cut it + # for a Uo of 0.01 W/m2•K (Rsi 100 or R568). Instead, an average PSI-factor + # of 0.090 (~45.6 W/K, very high performance) would allow compliance for a + # Uo of 0.1 W/m2•K (Rsi 10 or R57, ... $$$). + # + # 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. + # + # And if one were to instead model each of the OpenStudio walls described + # above as 2x distinct OpenStudio surfaces? e.g.: + # - 95% of exposed wall area Uo 0.01 W/m2•K + # - 5% of exposed wall area as a "thermal bridge" strip (~5.6 W/m2•K *) + # + # * (76.1 W/K over 5% of 273.6 m2) + # + # One would still consistently arrive at the same area-weighted average + # Ut, in this case 0.288 (> 0.277). No free lunches. + # + # --- + # + # TBD's "uprating" method reorders the equation and attempts the + # following: + # + # Uo = 0.277 - ( ∑psi • L )/A + # + # 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 + # - too thin - # Illustrating that story-specific PSI set is used when only 1x story. - surface[:edges].values.each do |edge| - next unless id.include?("t ") - next unless id.include?("Wall ") - next unless edge[:type] == :parapetconvex - next if id.include?(" C") + # -- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- -- # + # Retrying the previous example, yet requesting uprating calculations: + TBD.clean! - expect(edge[:psi]).to be_within(TOL).of(0.133) # 42.7% of 0.311 - end + model = translator.loadModel(path) + expect(model).to_not be_empty + model = model.get - # The shared :rimjoist between middle story and ground floor units could - # either inherit the "Building Story 1" or "Building Story 2" :rimjoist - # PSI values. TBD retains the most conductive PSI values in such cases. - surface[:edges].values.each do |edge| - next unless id.include?("m ") - next unless id.include?("Wall ") - next if id.include?(" C") - next unless edge[:type] == :rimjoist + argh = {} + argh[:option ] = "efficient (BETBG)" # all PSI-factors @ 0.2 W/K•m + argh[:uprate_walls] = true + argh[:uprate_roofs] = true + argh[:wall_option ] = "ALL wall constructions" + argh[:roof_option ] = "ALL roof constructions" + argh[:wall_ut ] = 0.210 # NECB CZ7 2017 (RSi 4.76 / R27) + argh[:roof_ut ] = 0.138 # NECB CZ7 2017 (RSi 7.25 / R41) - # Inheriting "Building Story 1" :rimjoist PSI of 0.501 W/K per metre. - # The SEA unit is above an office space below, which has curtain wall. - # RSi of insulation layers (to derate): - # - office walls : 0.740 m2.K/W (26.1%) - # - SEA walls : 2.100 m2.K/W (73.9%) - # - # - SEA walls : 26.1% of 0.501 = 0.3702 W/K per metre - # - other walls : 50.0% of 0.501 = 0.2505 W/K per metre - if ["m SWall SEA", "m EWall SEA"].include?(id) - expect(edge[:psi]).to be_within(0.002).of(0.3702) - else - expect(edge[:psi]).to be_within(0.002).of(0.2505) - end - end - end + 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 have_key(:roof_uo) + expect(argh[:wall_uo]).to_not be_nil + expect(argh[:roof_uo]).to_not be_nil - expect(counter).to eq(51) - end + # 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(UMIN) # RSi 100.00 (R568) + expect(argh[:roof_uo].round(3)).to eq(0.121) # RSi 8.26 ( R47) - it "can process seb.osm (UNCONDITIONED attic)" do - translator = OpenStudio::OSVersion::VersionTranslator.new - version = OpenStudio.openStudioVersion.split(".").join.to_i + # -- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- -- # + # Final attempt, with PSI-factors of 0.09 W/K per linear metre (JSON file). TBD.clean! - file = File.join(__dir__, "files/osms/in/seb.osm") - path = OpenStudio::Path.new(file) + walls = [] model = translator.loadModel(path) expect(model).to_not be_empty model = model.get - # Consider the plenum as UNCONDITIONED - not indirectly-conditioned. - plnum = model.getSpaceByName("Level 0 Ceiling Plenum") - expect(plnum).to_not be_empty - plnum = plnum.get - expect(TBD.unconditioned?(plnum)).to be false + argh = {} + argh[:io_path ] = File.join(__dir__, "../json/tbd_5ZoneNoHVAC.json") + argh[:schema_path ] = File.join(__dir__, "../tbd.schema.json") + argh[:uprate_walls] = true + argh[:uprate_roofs] = true + argh[:wall_option ] = "ALL wall constructions" + argh[:roof_option ] = "ALL roof constructions" + argh[:wall_ut ] = 0.210 # NECB CZ7 2017 (RSi 4.76 / R27) + argh[:roof_ut ] = 0.138 # NECB CZ7 2017 (RSi 7.25 / R41) - key = "space_conditioning_category" - val = "Unconditioned" - expect(plnum.additionalProperties.hasFeature(key)).to be false - expect(plnum.additionalProperties.setFeature(key, val)).to be true - expect(TBD.plenum?(plnum)).to be true # fyi, still has "plenum" spacetype - expect(TBD.unconditioned?(plnum)).to be true # ... more reliable - expect(TBD.setpoints(plnum)[:heating]).to be_nil - expect(TBD.setpoints(plnum)[:cooling]).to be_nil + 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.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].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| - expect(s.space).to_not be_empty + id = s.nameString + next unless s.surfaceType == "Wall" + next unless s.outsideBoundaryCondition == "Outdoors" + + 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) - id = c.nameString - name = s.nameString + 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 - if s.outsideBoundaryCondition.downcase == "outdoors" - expect(c.layers.size).to eq(4) - expect(c.layers[2].to_StandardOpaqueMaterial).to_not be_empty - m = c.layers[2].to_StandardOpaqueMaterial.get - r = m.thickness / m.thermalConductivity - expect(r).to be_within(TOL).of(1.47) if s.surfaceType == "Wall" - expect(r).to be_within(TOL).of(5.08) if s.surfaceType == "RoofCeiling" - elsif s.outsideBoundaryCondition == "Surface" - next unless s.surfaceType == "RoofCeiling" + insul = c.layers[2].to_StandardOpaqueMaterial + expect(insul).to_not be_empty + insul = insul.get + expect(insul.nameString).to include(" uprated m tbd") - expect(c.layers.size).to eq(1) - expect(c.layers[0].to_StandardOpaqueMaterial).to_not be_empty - m = c.layers[0].to_StandardOpaqueMaterial.get - r = m.thickness / m.thermalConductivity - expect(r).to be_within(TOL).of(0.12) - end + 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 - # Save model as UNCONDITIONED. - file = File.join(__dir__, "files/osms/out/unconditioned.osm") - model.save(file, true) + expect(m2.round(2)).to eq(273.60) + expect(uA.round(2)).to eq(57.45) - # The v1.11.5 (2016) seb.osm, shipped with OpenStudio, holds (what would now - # be considered as deprecated) a definition of plenum floors (i.e. ceiling - # tiles) generating quite a few warnings. From 'run/eplusout.err' (24.1.0): - # - # ** Warning ** GetSurfaceData: InterZone Surface Tilts do not match .... - # ** ~~~ ** Tilt=0.0 in Surface=LEVEL 0 ENTRY WAY CEILING PLENUM ... - # ** ~~~ ** Tilt=0.0 in Surface=ENTRY WAY DROPPEDCEILING, Zone ... - # ** Warning ** GetSurfaceData: InterZone Surface Classes do not match ... - # ** ~~~ ** Surface="LEVEL 0 ENTRY WAY CEILING PLENUM DROPPEDCEILING" - # ** ~~~ ** Adjacent Surface="ENTRY WAY DROPPEDCEILING", surface ... - # ** ~~~ ** Other errors/warnings may follow about these surfaces. - # ** Warning ** GetSurfaceData: InterZone Surface Tilts do not match .... - # ** ~~~ ** Tilt=0.0 in Surface=LEVEL 0 OPEN AREA 1 CEILING PLENUM ... - # ** ~~~ ** Tilt=0.0 in Surface=OPEN AREA 1 DROPPEDCEILING, ... - # ** Warning ** GetSurfaceData: InterZone Surface Classes do not match .... - # ** ~~~ ** Surface="LEVEL 0 OPEN AREA 1 CEILING PLENUM DROPPEDCEILING", - # ** ~~~ ** Adjacent Surface="OPEN AREA 1 DROPPEDCEILING", surface ... - # ** ~~~ ** Other errors/warnings may follow about these surfaces. - # ** Warning ** GetSurfaceData: InterZone Surface Tilts do not match .... - # ** ~~~ ** Tilt=0.0 in Surface=LEVEL 0 SMALL OFFICE 1 CEILING ... - # ** ~~~ ** Tilt=0.0 in Surface=SMALL OFFICE 1 DROPPEDCEILING, ... - # ** Warning ** GetSurfaceData: InterZone Surface Classes do not match .... - # ** ~~~ ** Surface="LEVEL 0 SMALL OFFICE 1 CEILING PLENUM ... - # ** ~~~ ** Adjacent Surface="SMALL OFFICE 1 DROPPEDCEILING", ... - # ** ~~~ ** Other errors/warnings may follow about these surfaces. - # ** Warning ** GetSurfaceData: InterZone Surface Tilts do not match .... - # ** ~~~ ** Tilt=0.0 in Surface=LEVEL 0 UTILITY 1 CEILING PLENUM ... - # ** ~~~ ** Tilt=0.0 in Surface=UTILITY 1 DROPPEDCEILING, ... - # ** Warning ** GetSurfaceData: InterZone Surface Classes do not match .... - # ** ~~~ ** Surface="LEVEL 0 UTILITY 1 CEILING PLENUM DROPPEDCEILING", - # ** ~~~ ** Adjacent Surface="UTILITY 1 DROPPEDCEILING", surface ... - # ** ~~~ ** Other errors/warnings may follow about these surfaces. - # ** Warning ** No floor exists in Zone="LEVEL 0 CEILING PLENUM ZONE", ... - # ** Warning ** CalculateZoneVolume: 1 zone is not fully enclosed ... + # Reach NECB required Ut for walls? + ut = uA / m2 + expect(ut.round(3)).to eq(argh[:wall_ut].round(3)) # 0.210 - # Ensuring TBD similarly derates model surfaces, before vs after the fix. In - # other words, TBD doesn't trip over a plenum "Floor" vs "RoofCeiling" when - # the plenum is UNCONDITIONED like a vented attic. - 2.times do |time| - unless time.zero? - file = File.join(__dir__, "files/osms/out/unconditioned.osm") - path = OpenStudio::Path.new(file) - model = translator.loadModel(path) - expect(model).to_not be_empty - model = model.get + 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].round(3)).to eq(11.205) # R64 + expect(surfaces[wall][:u].round(3)).to eq( 0.086) # R66 + end - # "Shading Surface 4" is overlapping with a plenum exterior wall. - sh4 = model.getShadingSurfaceByName("Shading Surface 4") - expect(sh4).to_not be_empty - sh4 = sh4.get - sh4.remove + # -- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- -- # + # Realistic, BTAP-costed PSI-factors. + TBD.clean! - plnum = model.getSpaceByName("Level 0 Ceiling Plenum") - expect(plnum).to_not be_empty - plnum = plnum.get - expect(TBD.unconditioned?(plnum)).to be true + jpath = "../json/tbd_5ZoneNoHVAC_btap.json" + file = File.join(__dir__, "files/osms/in/5ZoneNoHVAC.osm") + path = OpenStudio::Path.new(file) + model = translator.loadModel(path) + expect(model).to_not be_empty + model = model.get - thzone = plnum.thermalZone - expect(thzone).to_not be_empty - thzone = thzone.get + # Assign (missing) space types. + north = model.getSpaceByName("Story 1 North Perimeter Space") + east = model.getSpaceByName("Story 1 East Perimeter Space") + south = model.getSpaceByName("Story 1 South Perimeter Space") + west = model.getSpaceByName("Story 1 West Perimeter Space") + core = model.getSpaceByName("Story 1 Core Space") - # Before the fix. - unless version < 350 - expect(plnum.isEnclosedVolume).to be true - expect(plnum.isVolumeDefaulted).to be true - expect(plnum.isVolumeAutocalculated).to be true - end + expect(north).to_not be_empty + expect(east ).to_not be_empty + expect(south).to_not be_empty + expect(west ).to_not be_empty + expect(core ).to_not be_empty - if version > 350 && version < 370 - expect(plnum.volume.round(0)).to eq(234) - else - expect(plnum.volume.round(0)).to eq(0) - end + north = north.get + east = east.get + south = south.get + west = west.get + core = core.get - expect(thzone.isVolumeDefaulted).to be true - expect(thzone.isVolumeAutocalculated).to be true - expect(thzone.volume).to be_empty + audience = OpenStudio::Model::SpaceType.new(model) + warehouse = OpenStudio::Model::SpaceType.new(model) + offices = OpenStudio::Model::SpaceType.new(model) + sales = OpenStudio::Model::SpaceType.new(model) + workshop = OpenStudio::Model::SpaceType.new(model) - plnum.surfaces.each do |s| - next if s.outsideBoundaryCondition.downcase == "outdoors" + audience.setName("Audience - auditorium") + warehouse.setName("Warehouse - fine") + offices.setName("Office - enclosed") + sales.setName("Sales area") + workshop.setName("Workshop space") - # If a SEB plenum surface isn't facing outdoors, it's 1 of 4 "floor" - # surfaces (each facing a ceiling surface below). - adj = s.adjacentSurface - expect(adj).to_not be_empty - adj = adj.get - expect(adj.vertices.size).to eq(s.vertices.size) + expect(north.setSpaceType(audience )).to be true + expect( east.setSpaceType(warehouse)).to be true + expect(south.setSpaceType(offices )).to be true + expect( west.setSpaceType(sales )).to be true + expect( core.setSpaceType(workshop )).to be true - # Same vertex sequence? Should be in reverse order. - adj.vertices.each_with_index do |vertex, i| - expect(TBD.same?(vertex, s.vertices.at(i))).to be true - end + argh = {} + argh[:io_path ] = File.join(__dir__, jpath) + argh[:schema_path ] = File.join(__dir__, "../tbd.schema.json") + argh[:uprate_walls] = true + argh[:wall_option ] = "ALL wall constructions" + argh[:wall_ut ] = 0.210 # NECB CZ7 2017 (RSi 4.76 / R41) - expect(adj.surfaceType).to eq("RoofCeiling") - expect(s.surfaceType).to eq("RoofCeiling") - expect(s.setSurfaceType("Floor")).to be true - expect(s.setVertices(s.vertices.reverse)).to be true + 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 ") - # Vertices now in reverse order. - adj.vertices.reverse.each_with_index do |vertex, i| - expect(TBD.same?(vertex, s.vertices.at(i))).to be true - end - end + expect(argh).to_not have_key(:roof_uo) + expect(argh).to have_key(:wall_uo) - # Save for future testing. - file = File.join(__dir__, "files/osms/out/unconditioned2.osm") - model.save(file, true) + # 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 + # (as per NECB requirements). From v3.5.0+, OpenStudio dropped the maximum + # layer thickness limit, harmonizing with EnergyPlus: + # + # https://github.com/NREL/OpenStudio/pull/4622 + # + # This didn't mean EnergyPlus wouldn't halt a simulation due to invalid CTF + # calculations - happens with very thick materials. Recent 2025 TBD changes + # have removed this check. Users of pre-v3.5.X OpenStudio should expect + # OS-generated simulation failures when uprating (extremes cases). Achtung! + expect(argh[:wall_uo].round(4)).to eq(UMIN) # RSi 100 (R568) - # After the fix. - unless version < 350 - expect(plnum.isEnclosedVolume).to be true - expect(plnum.isVolumeDefaulted).to be true - expect(plnum.isVolumeAutocalculated).to be true - end + nb = 0 + m2 = 0 + uA = 0 - expect(plnum.volume.round(0)).to eq(50) - expect(thzone.isVolumeDefaulted).to be true - expect(thzone.isVolumeAutocalculated).to be true - expect(thzone.volume).to be_empty - end + model.getSurfaces.each do |s| + next unless s.surfaceType.downcase == "wall" + next unless s.outsideBoundaryCondition.downcase == "outdoors" - argh = {option: "poor (BETBG)"} + c = s.construction + expect(c).to_not be_empty + c = c.get.to_LayeredConstruction + next if c.empty? - 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.status).to be_zero - expect(TBD.logs).to be_empty - expect(surfaces).to be_a(Hash) - expect(surfaces.size).to eq(56) - expect(io).to be_a(Hash) - expect(io).to have_key(:edges) - expect(io[:edges].size).to eq(80) + c = c.get + expect(c.nameString).to include("c tbd") - edges = io[:edges] - edges = edges.reject { |s| s.to_s.include?("sill" ) } - edges = edges.reject { |s| s.to_s.include?("head" ) } - edges = edges.reject { |s| s.to_s.include?("jamb" ) } - edges = edges.reject { |s| s.to_s.include?("grade" ) } - edges = edges.reject { |s| s.to_s.include?("corner") } - edges = edges.reject { |s| s.to_s.include?("sill" ) } + lyr = TBD.insulatingLayer(c) + expect(lyr).to be_a(Hash) + expect(lyr).to have_key(:type) + expect(lyr).to have_key(:index) + expect(lyr).to have_key(:r) + expect(lyr[:type]).to eq(:standard) + expect(lyr[:index]).to be_between(0, c.numLayers) + insul = c.getLayer(lyr[:index]) + insul = insul.to_StandardOpaqueMaterial + expect(insul).to_not be_empty + insul = insul.get + expect(insul.thickness.round(3)).to eq(DMAX) # 1m - expect(edges.size).to eq(26) + r = TBD.rsi(c, s.filmResistance) + m2 += s.netArea + uA += s.netArea / r + nb += 1 + end - edges.each do |edge| - type = edge[:type ] - size = edge[:surfaces].size - shades = edge[:surfaces].select { |s| s.include?("Shading") } - walls = edge[:surfaces].select { |s| s.include?("Wall") } - ceilings = edge[:surfaces].select { |s| s.include?("DroppedCeiling") } + ut = uA / m2 + expect((uA/m2).round(2)).to eq(argh[:wall_ut].round(2)) + expect(nb).to eq(4) + end - pceilings = ceilings.select { |s| s.include?("Plenum") } - expect(type).to eq(:transition).or eq(:parapetconvex) + it "can test Hash inputs" do + translator = OpenStudio::OSVersion::VersionTranslator.new + TBD.clean! - if type == :transition - if size == 2 || size == 4 - expect(walls.size).to eq(size) - elsif size == 3 - expect(shades.size).to eq(1) - expect(walls.size).to eq(2) - else - expect(size).to eq(6) - # ... shared between: - # - 1x paired interior walls = 2x - # - 2x pairs of adjacent ceilings (either side) = 4x - # ___________________________________________ = 6x in TOTAL - expect(walls.size).to eq(2) - expect(ceilings.size).to eq(4) - expect(pceilings.size).to eq(2) - end - else - # ... shared between: - # - 1x exterior wall (occupied space) = 1x - # - 1x plenum wall overhead = 1x - # - 1x shading (maybe) - # - 1x pair of adjacent ceilings (either side) = 2x - # _____________________________________________ = 4x (or 5x) in TOTAL - if size == 5 - expect(shades.size).to eq(1) - else - expect(size).to eq(4) - expect(shades.size).to eq(0) - end - - expect(walls.size).to eq(2) - expect(ceilings.size).to eq(2) - expect(pceilings.size).to eq(1) - end - end + input = {} + schema = "https://github.com/rd2/tbd/blob/master/tbd.schema.json" + file = File.join(__dir__, "files/osms/out/seb2.osm") + path = OpenStudio::Path.new(file) + model = translator.loadModel(path) + expect(model).to_not be_empty + model = model.get - ids = { a: "Entryway Wall 4", - b: "Entryway Wall 5", - c: "Entryway Wall 6", - d: "Entry way DroppedCeiling", - e: "Utility1 Wall 1", - f: "Utility1 Wall 5", - g: "Utility 1 DroppedCeiling", - h: "Smalloffice 1 Wall 1", - i: "Smalloffice 1 Wall 2", - j: "Smalloffice 1 Wall 6", - k: "Small office 1 DroppedCeiling", - l: "Openarea 1 Wall 3", - m: "Openarea 1 Wall 4", - n: "Openarea 1 Wall 5", - o: "Openarea 1 Wall 6", - p: "Openarea 1 Wall 7", - q: "Open area 1 DroppedCeiling" - }.freeze + # Rather than reading a TBD JSON input file (e.g. "json/tbd_seb_n2.json"), + # read in the same content as a Hash. Better for scripted batch runs. + psis = [] + khis = [] + surfaces = [] - surfaces.each do |id, surface| - expect(surface).to have_key(:deratable) - expect(surface).to have_key(:conditioned) - expect(surface).to have_key(:space) - space = surface[:space] - next unless surface[:deratable] + psi = {} + psi[:id ] = "good" + psi[:parapet ] = 0.500 + psi[:party ] = 0.900 + psis << psi - expect(surface[:conditioned]).to be false if space == plnum - expect(surface[:conditioned]).to be true unless space == plnum - expect(ids).to_not have_value(id) if space == plnum - expect(ids).to have_value(id) unless space == plnum - next unless surface[:conditioned] + psi = {} + psi[:id ] = "compliant" + psi[:rimjoist ] = 0.300 + psi[:parapet ] = 0.325 + psi[:fenestration ] = 0.350 + psi[:corner ] = 0.450 + psi[:balcony ] = 0.500 + psi[:party ] = 0.500 + psi[:grade ] = 0.450 + psis << psi - expect(surface).to have_key(:edges) - expect(surface).to have_key(:heating) - expect(surface).to have_key(:cooling) - end + khi = {} + khi[:id ] = "column" + khi[:point ] = 0.500 + khis << khi - surfaces.each do |id, surface| - next unless surface.key?(:edges) + khi = {} + khi[:id ] = "support" + khi[:point ] = 0.500 + khis << khi - expect(surface).to have_key(:ratio) - expect(surface).to have_key(:heatloss) - h = surface[:heatloss] - s = model.getSurfaceByName(id) - expect(s).to_not be_empty - s = s.get - expect(s.nameString).to eq(id) - expect(s.isConstructionDefaulted).to be false - expect(s.construction.get.nameString).to include(" tbd") - expect(h).to be_within(TOL).of( 6.43) if id == ids[:a] - expect(h).to be_within(TOL).of(11.18) if id == ids[:b] - expect(h).to be_within(TOL).of( 4.56) if id == ids[:c] - expect(h).to be_within(TOL).of( 0.42) if id == ids[:d] - expect(h).to be_within(TOL).of(12.66) if id == ids[:e] - expect(h).to be_within(TOL).of(12.59) if id == ids[:f] - expect(h).to be_within(TOL).of( 0.50) if id == ids[:g] - expect(h).to be_within(TOL).of(14.06) if id == ids[:h] - expect(h).to be_within(TOL).of( 9.04) if id == ids[:i] - expect(h).to be_within(TOL).of( 8.75) if id == ids[:j] - expect(h).to be_within(TOL).of( 0.53) if id == ids[:k] - expect(h).to be_within(TOL).of( 5.06) if id == ids[:l] - expect(h).to be_within(TOL).of( 6.25) if id == ids[:m] - expect(h).to be_within(TOL).of( 9.04) if id == ids[:n] - expect(h).to be_within(TOL).of( 6.74) if id == ids[:o] - expect(h).to be_within(TOL).of( 4.32) if id == ids[:p] - expect(h).to be_within(TOL).of( 0.76) if id == ids[:q] + surface = {} + surface[:id ] = "Entryway Wall 5" + surface[:khis ] = [] + surface[:khis ] << { id: "column", count: 3 } + surface[:khis ] << { id: "support", count: 4 } + surfaces << surface - c = s.construction - expect(c).to_not be_empty - c = c.get.to_LayeredConstruction - expect(c).to_not be_empty - c = c.get - i = 0 - i = 2 if s.outsideBoundaryCondition.downcase == "outdoors" - expect(c.layers[i].nameString).to include("m tbd") - end + input[:schema ] = schema + input[:description] = "testing JSON surface KHI entries" + input[:psis ] = psis + input[:khis ] = khis + input[:surfaces ] = surfaces - surfaces.each do |id, surface| - expect(surface).to have_key(:filmRSI) + # Export to file. Both files should be the same. + out = JSON.pretty_generate(input) + pth = File.join(__dir__, "../json/tbd_seb_n2.out.json") + File.open(pth, "w") { |pth| pth.puts out } + initial = File.join(__dir__, "../json/tbd_seb_n2.json") + expect(FileUtils).to be_identical(initial, pth) - if surface.key?(:ratio) - expect(surface[:ratio]).to be_within(0.1).of(-36.74) if id == ids[:a] - expect(surface[:ratio]).to be_within(0.1).of(-34.61) if id == ids[:b] - expect(surface[:ratio]).to be_within(0.1).of(-33.57) if id == ids[:c] - expect(surface[:ratio]).to be_within(0.1).of( -0.14) if id == ids[:d] - expect(surface[:ratio]).to be_within(0.1).of(-35.09) if id == ids[:e] - expect(surface[:ratio]).to be_within(0.1).of(-35.12) if id == ids[:f] - expect(surface[:ratio]).to be_within(0.1).of( -0.13) if id == ids[:g] - expect(surface[:ratio]).to be_within(0.1).of(-39.75) if id == ids[:h] - expect(surface[:ratio]).to be_within(0.1).of(-39.74) if id == ids[:i] - expect(surface[:ratio]).to be_within(0.1).of(-39.90) if id == ids[:j] - expect(surface[:ratio]).to be_within(0.1).of( -0.13) if id == ids[:k] - expect(surface[:ratio]).to be_within(0.1).of(-27.78) if id == ids[:l] - expect(surface[:ratio]).to be_within(0.1).of(-31.66) if id == ids[:m] - expect(surface[:ratio]).to be_within(0.1).of(-28.44) if id == ids[:n] - expect(surface[:ratio]).to be_within(0.1).of(-30.85) if id == ids[:o] - expect(surface[:ratio]).to be_within(0.1).of(-28.78) if id == ids[:p] - expect(surface[:ratio]).to be_within(0.1).of( -0.09) if id == ids[:q] - next unless id == ids[:a] + argh = {} + argh[:option ] = "(non thermal bridging)" + argh[:io_path ] = input + argh[:schema_path ] = File.join(__dir__, "../tbd.schema.json") - s = model.getSurfaceByName(id) - expect(s).to_not be_empty - s = s.get - expect(s.nameString).to eq(id) - expect(s.surfaceType).to eq("Wall") - expect(s.isConstructionDefaulted).to be false - c = s.construction.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) - expect(c.layers[2].nameString).to include("m tbd") - expect(c.layers[2].to_StandardOpaqueMaterial).to_not be_empty - m = c.layers[2].to_StandardOpaqueMaterial.get + 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.status).to be_zero + expect(TBD.logs).to be_empty + expect(surfaces).to be_a(Hash) + expect(surfaces.size).to eq(56) + expect(io).to be_a(Hash) + expect(io).to have_key(:edges) + expect(io[:edges].size).to eq(106) - initial_R = surface[:filmRSI] + 2.4674 - derated_R = surface[:filmRSI] + 0.9931 - derated_R += m.thickness / m.thermalConductivity + surfaces.values.each do |surface| + next unless surface.key?(:ratio) - ratio = -(initial_R - derated_R) * 100 / initial_R - expect(ratio).to be_within(1).of(surfaces[id][:ratio]) - else - if surface[:boundary] == "outdoors" - expect(surface[:conditioned]).to be false - end - end - end + expect(surface[:heatloss]).to be_within(TOL).of(3.5) end - - # MODEL VARIANT annual GJ (PRE-TBD) - # ________________________ _________ - # unconditioned SEB 257.04 - # fixed unconditioned SEB 258.40 - # ________________________ _________ - # +1.36 (+0.5%) ... QC City, OS v3.6.1 - # - # A diff comparison of both generated .osm files do not reveal changes other - # than the aforementioned fixes (before running TBD). Boils down to removing - # the fixed shading? "Floor" vs "RoofCeiling" heat transfer coefficients? - # In any case, GJ differences are about the same (pre- vs post-TBD). - # - # MODEL VARIANT annual GJ (POST-TBD) - # ________________________ _________ - # unconditioned SEB 262.70 - # fixed unconditioned SEB 264.05 - # ________________________ _________ - # +1.35 (+0.5%) ... QC City, OS v3.6.1 end - it "can process seb.osm (CONDITIONED plenum)" do + it "can check for attics vs plenums" do translator = OpenStudio::OSVersion::VersionTranslator.new - version = OpenStudio.openStudioVersion.split(".").join.to_i TBD.clean! + # Outdoor-facing surfaces of UNCONDITIONED spaces are never derated by TBD. + # Yet determining whether an OpenStudio space should be considered + # UNCONDITIONED (e.g. an attic), rather than INDIRECTLYCONDITIONED + # (e.g. a plenum) can be tricky depending on the (incomplete) state of + # development of an OpenStudio model. In determining the conditioning + # status of each OpenStudio space, TBD relies on OSut methods: + # - 'setpoints(space)': applicable space heating/cooling setpoints + # - 'heatingTemperatureSetpoints?': ANY space holding heating setpoints? + # - 'coolingTemperatureSetpoints?': ANY space holding cooling setpoints? + # + # Users can consult the online OSut API documentation to know more. - file = File.join(__dir__, "files/osms/in/seb.osm") + # Small office test case (UNCONDITIONED attic). + file = File.join(__dir__, "files/osms/in/smalloffice.osm") path = OpenStudio::Path.new(file) model = translator.loadModel(path) expect(model).to_not be_empty model = model.get + attic = model.getSpaceByName("Attic") + expect(attic).to_not be_empty + attic = attic.get - # Out of the box, plenum is INDIRECTLY-CONDITIONED - not UNCONDITIONED. - plnum = model.getSpaceByName("Level 0 Ceiling Plenum") - expect(plnum).to_not be_empty - plnum = plnum.get - - expect(TBD.plenum?(plnum)).to be true # has "plenum" spacetype - expect(TBD.unconditioned?(plnum)).to be false - expect(TBD.setpoints(plnum)[:heating].to_i).to eq(21) - expect(TBD.setpoints(plnum)[:cooling].to_i).to eq(24) - expect(TBD.status).to be_zero - - # Contrary to the previous "seb.osm (UNCONDITIONED) attic" RSpec, the fix - # triggers TBD to label as ":ceiling" edges shared by: - # - 1x plenum "Floor" - # - 1x adjacent (occupied) room "RoofCeiling" - # - 1x plenum outdoor-facing "Wall" - # - 1x (occupied) room outdoor-facing "Wall" - # - # Before the fix, TBD labels these same edges as ":transition". In normal - # circumstances, this wouldn't usually affect simulation results, as both - # :transition and :ceiling PSI-factors would normally be set to 0.0 W/K per - # linear meter. But users remain free to reset either value, so ... - 2.times do |time| - unless time.zero? - file = File.join(__dir__, "files/osms/in/seb.osm") - path = OpenStudio::Path.new(file) - model = translator.loadModel(path) - expect(model).to_not be_empty - model = model.get + model.getSpaces.each do |space| + next if space == attic - # "Shading Surface 4" is overlapping with a plenum exterior wall. - sh4 = model.getShadingSurfaceByName("Shading Surface 4") - expect(sh4).to_not be_empty - sh4 = sh4.get - sh4.remove + zone = space.thermalZone + expect(zone).to_not be_empty + zone = zone.get + heat = TBD.maxHeatScheduledSetpoint(zone) + cool = TBD.minCoolScheduledSetpoint(zone) - plnum = model.getSpaceByName("Level 0 Ceiling Plenum") - expect(plnum).to_not be_empty - plnum = plnum.get + expect(heat[:spt]).to be_within(TOL).of(21.11) + expect(cool[:spt]).to be_within(TOL).of(23.89) + expect(heat[:dual]).to be true + expect(cool[:dual]).to be true - thzone = plnum.thermalZone - expect(thzone).to_not be_empty - thzone = thzone.get + expect(space.partofTotalFloorArea).to be true + expect(TBD.plenum?(space)).to be false + expect(TBD.unconditioned?(space)).to be false + expect(TBD.setpoints(space)[:heating]).to be_within(TOL).of(21.11) + expect(TBD.setpoints(space)[:cooling]).to be_within(TOL).of(23.89) + end - # Before the fix. - unless version < 350 - expect(plnum.isEnclosedVolume).to be true - expect(plnum.isVolumeDefaulted).to be true - expect(plnum.isVolumeAutocalculated).to be true - end + zone = attic.thermalZone + expect(zone).to_not be_empty + zone = zone.get + heat = TBD.maxHeatScheduledSetpoint(zone) + cool = TBD.minCoolScheduledSetpoint(zone) - if version > 350 && version < 370 - expect(plnum.volume.round(0)).to eq(234) - else - expect(plnum.volume.round(0)).to eq(0) - end + expect(heat[:spt ]).to be_nil + expect(cool[:spt ]).to be_nil + expect(heat[:dual]).to be false + expect(cool[:dual]).to be false - expect(thzone.isVolumeDefaulted).to be true - expect(thzone.isVolumeAutocalculated).to be true - expect(thzone.volume).to be_empty + expect(TBD.plenum?(attic)).to be false + expect(TBD.unconditioned?(attic)).to be true + expect(TBD.setpoints(attic)[:heating]).to be_nil + expect(TBD.setpoints(attic)[:cooling]).to be_nil + expect(attic.partofTotalFloorArea).to be false + expect(TBD.status).to be_zero - plnum.surfaces.each do |s| - next if s.outsideBoundaryCondition.downcase == "outdoors" + argh = { option: "code (Quebec)" } + json = TBD.process(model, argh) + expect(TBD.status).to be_zero + expect(json).to be_a(Hash) + expect(json).to have_key(:io) + expect(json).to have_key(:surfaces) + io = json[:io ] + surfaces = json[:surfaces] + expect(surfaces).to be_a(Hash) + expect(surfaces.size).to eq(43) + expect(io).to have_key(:edges) + expect(io[:edges].size).to eq(105) - # If a SEB plenum surface isn't facing outdoors, it's 1 of 4 "floor" - # surfaces (each facing a ceiling surface below). - adj = s.adjacentSurface - expect(adj).to_not be_empty - adj = adj.get - expect(adj.vertices.size).to eq(s.vertices.size) + surfaces.each do |id, surface| + next unless id.include?("_roof_") - # Same vertex sequence? Should be in reverse order. - adj.vertices.each_with_index do |vertex, i| - expect(TBD.same?(vertex, s.vertices.at(i))).to be true - end + expect(id).to include("Attic") + expect(surface).to_not have_key(:ratio) + expect(surface).to have_key(:conditioned) + expect(surface).to have_key(:deratable) + expect(surface[:conditioned]).to be false + expect(surface[:deratable]).to be false + end - expect(adj.surfaceType).to eq("RoofCeiling") - expect(s.surfaceType).to eq("RoofCeiling") - expect(s.setSurfaceType("Floor")).to be true - expect(s.setVertices(s.vertices.reverse)).to be true + # Now tag attic as an INDIRECTLYCONDITIONED space (linked to "Core_ZN"). + file = File.join(__dir__, "files/osms/in/smalloffice.osm") + path = OpenStudio::Path.new(file) + model = translator.loadModel(path) + expect(model).to_not be_empty + model = model.get + attic = model.getSpaceByName("Attic") + expect(attic).to_not be_empty + attic = attic.get - # Vertices now in reverse order. - adj.vertices.reverse.each_with_index do |vertex, i| - expect(TBD.same?(vertex, s.vertices.at(i))).to be true - end - end + key = "indirectlyconditioned" + val = "Core_ZN" + expect(attic.additionalProperties.setFeature(key, val)).to be true + expect(TBD.plenum?(attic)).to be false + expect(TBD.unconditioned?(attic)).to be false + expect(TBD.setpoints(attic)[:heating]).to be_within(TOL).of(21.11) + expect(TBD.setpoints(attic)[:cooling]).to be_within(TOL).of(23.89) + expect(TBD.status).to be_zero - # Save for future testing. - file = File.join(__dir__, "files/osms/out/seb2.osm") - model.save(file, true) + 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) + io = json[:io ] + surfaces = json[:surfaces] + expect(TBD.status).to be_zero + expect(surfaces).to be_a(Hash) + expect(surfaces.size).to eq(43) + expect(io).to have_key(:edges) + expect(io[:edges].size).to eq(110) - # After the fix. - unless version < 350 - expect(plnum.isEnclosedVolume).to be true - expect(plnum.isVolumeDefaulted).to be true - expect(plnum.isVolumeAutocalculated).to be true - end + surfaces.each do |id, surface| + next unless id.include?("_roof_") - expect(plnum.volume.round(0)).to eq(50) # right answer - expect(thzone.isVolumeDefaulted).to be true - expect(thzone.isVolumeAutocalculated).to be true - expect(thzone.volume).to be_empty - end + expect(id).to include("Attic") + expect(surface).to have_key(:ratio) + expect(surface).to have_key(:conditioned) + expect(surface).to have_key(:deratable) + expect(surface[:conditioned]).to be true + expect(surface[:deratable]).to be true + end - argh = {option: "poor (BETBG)"} + expect(attic.additionalProperties.resetFeature(key)).to be true - 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.status).to be_zero - expect(TBD.logs).to be_empty - expect(surfaces).to be_a(Hash) - expect(surfaces.size).to eq(56) - expect(io).to be_a(Hash) - expect(io).to have_key(:edges) - expect(io[:edges].size).to eq(106) # not 80 as if it were UNCONDITIONED + # Adding a sub surface between UNCONDITIONED Attic & CONDITIONED Core. + file = File.join(__dir__, "files/osms/in/smalloffice.osm") + path = OpenStudio::Path.new(file) + model = translator.loadModel(path) + expect(model).to_not be_empty + model = model.get - edges = io[:edges] - edges = edges.reject { |s| s.to_s.include?("sill" ) } - edges = edges.reject { |s| s.to_s.include?("head" ) } - edges = edges.reject { |s| s.to_s.include?("jamb" ) } - edges = edges.reject { |s| s.to_s.include?("grade" ) } - edges = edges.reject { |s| s.to_s.include?("corner") } - edges = edges.reject { |s| s.to_s.include?("sill" ) } + floor = model.getSurfaceByName("Attic_floor_core") + expect(floor).to_not be_empty + floor = floor.get - expect(edges.size).to eq(44) + ceiling = floor.adjacentSurface + expect(ceiling).to_not be_empty + ceiling = ceiling.get - edges.each do |edge| - type = edge[:type ] - size = edge[:surfaces].size - shades = edge[:surfaces].select { |s| s.include?("Shading") } - walls = edge[:surfaces].select { |s| s.include?("Wall") } - ceilings = edge[:surfaces].select { |s| s.include?("DroppedCeiling") } - roofs = edge[:surfaces].select { |s| s.include?("RoofCeiling") } + sub = {} + sub[:id ] = "attic trap door" + sub[:type ] = "Door" + sub[:assembly] = TBD.genConstruction(model, {type: :door}) + sub[:width ] = 1.0 + sub[:height ] = 1.0 + expect(TBD.addSubs(floor, sub, false, true, true)).to be true + expect(TBD.addSubs(ceiling, sub, false, true, false)).to be true + expect(floor.subSurfaces.size).to eq(1) + expect(ceiling.subSurfaces.size).to eq(1) + trap = floor.subSurfaces.first + door = ceiling.subSurfaces.first + expect(trap.setAdjacentSubSurface(door)).to be true + expect(door.setAdjacentSubSurface(trap)).to be true + expect(trap.adjacentSubSurface).to_not be_empty + expect(door.adjacentSubSurface).to_not be_empty + expect(trap.adjacentSubSurface.get).to eq(door) + expect(door.adjacentSubSurface.get).to eq(trap) - pceilings = ceilings.select { |s| s.include?("Plenum") } + argh = { option: "code (Quebec)" } + json = TBD.process(model, argh) + expect(TBD.status).to be_zero + expect(json).to be_a(Hash) + expect(json).to have_key(:io) + expect(json).to have_key(:surfaces) + io = json[:io ] + surfaces = json[:surfaces] + expect(surfaces).to be_a(Hash) + expect(surfaces.size).to eq(43) + expect(io).to have_key(:edges) + expect(io[:edges].size).to eq(109) - expect(type).to eq(:transition).or eq(:parapetconvex).or eq(:ceiling) - expect(type).to_not eq(:ceiling) if time == 0 # :transition instead + file = File.join(__dir__, "files/osms/out/trapdoor.osm") + model.save(file, true) - if type == :transition - if time == 1 - expect(size).to eq(2).or eq(3).or eq(4) # not 5 - expect(walls.size).to eq(size) if size == 4 - else - expect(size).to eq(2).or eq(3).or eq(4).or eq(5) - end - - if size == 2 # between 2x exterior walls OR 2x plenum roof surfaces - next if walls.size == size + # Adding skylights/wells. + file = File.join(__dir__, "files/osms/in/smalloffice.osm") + path = OpenStudio::Path.new(file) + model = translator.loadModel(path) + expect(model).to_not be_empty + model = model.get - expect(walls.size).to eq(0) - expect(ceilings.size).to eq(0) - expect(roofs.size).to eq(2) - expect(pceilings.size).to eq(0) - elsif size == 3 - expect(shades.size).to eq(1) - expect(walls.size).to eq(2) - elsif size == 4 # between 2x room ceilings, along 2x exterior walls - next if walls.size == size + srr = 0.05 + gra = TBD.grossRoofArea(model.getSpaces) + tm2 = srr * gra + rm2 = TBD.addSkyLights(model.getSpaces, {area: tm2}) + expect(TBD.status).to be_zero + expect(rm2.round(2)).to eq(gra.round(2)) - # Holds "Shading Surface 4"? Then it's before the fix. - if shades.size == 2 - expect(time).to eq(0) - expect(walls.size).to eq(2) - expect(shades).to include("Shading Surface 4") - next - end + argh = {} + argh[:option ] = "efficient (BETBG)" + argh[:uprate_walls] = true + argh[:uprate_roofs] = true + argh[:wall_option ] = "ALL wall constructions" + argh[:roof_option ] = "ALL roof constructions" + argh[:wall_ut ] = 0.215 # NECB 2020 CZ7A (RSi 4.65 / R26) + argh[:roof_ut ] = 0.121 # NECB 2020 CZ7A (RSi 8.26 / R47) + json = TBD.process(model, argh) + expect(TBD.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) + io = json[:io ] + surfaces = json[:surfaces] + expect(surfaces).to be_a(Hash) + expect(surfaces.size).to eq(79) + expect(io).to have_key(:edges) + expect(io[:edges].size).to eq(173) - expect(walls.size).to eq(2) - expect(ceilings.size).to eq(2) - expect(roofs.size).to eq(0) - expect(pceilings.size).to eq(1) - else - expect(time).to eq(0) - expect(size).to eq(5) - expect(shades.size).to eq(1) - expect(shades).to include("Shading Surface 4") - expect(walls.size).to eq(2) - expect(ceilings.size).to eq(2) - expect(roofs.size).to eq(0) - expect(pceilings.size).to eq(1) - end - elsif type == :parapetconvex - if size == 4 - expect(time).to eq(0) - expect(shades.size).to eq(2) - expect(shades).to include("Shading Surface 4") - expect(walls.size).to eq(1) - expect(ceilings.size).to eq(0) - expect(roofs.size).to eq(1) - next - elsif size == 3 - expect(time).to eq(1) - expect(shades.size).to eq(1) - expect(shades).to_not include("Shading Surface 4") - expect(walls.size).to eq(1) - expect(ceilings.size).to eq(0) - expect(roofs.size).to eq(1) - else - expect(size).to eq(2) - expect(shades.size).to eq(0) - expect(walls.size).to eq(1) - expect(ceilings.size).to eq(0) - expect(roofs.size).to eq(1) - end - else - expect(time).to eq(1) - expect(type).to eq(:ceiling) - expect(size).to eq(4) - expect(shades.size).to eq(0) - expect(walls.size).to eq(2) - expect(ceilings.size).to eq(2) - expect(pceilings.size).to eq(1) - expect(roofs.size).to eq(0) - end - end - end - end + file = File.join(__dir__, "files/osms/out/office_attic_sky.osm") + model.save(file, true) - it "can take in custom (expansion) joints as thermal bridges" do - translator = OpenStudio::OSVersion::VersionTranslator.new TBD.clean! - file = File.join(__dir__, "files/osms/in/warehouse.osm") + # -- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- -- # + # 5Zone_2 test case (as INDIRECTLYCONDITIONED plenum). + plenum_walls = [] + plnum_walls = ["WALL-1PB", "WALL-1PF", "WALL-1PL", "WALL-1PR"] + other_ceilings = ["C1-1", "C2-1", "C3-1", "C4-1", "C5-1"] + + file = File.join(__dir__, "files/osms/in/5Zone_2.osm") path = OpenStudio::Path.new(file) model = translator.loadModel(path) expect(model).to_not be_empty model = model.get - # TBD will automatically tag as a (mild) "transition" any shared edge - # between 2x linked walls that +/- share the same 3D plane. An edge shared - # between 2x roof surfaces will equally be tagged as a "transition" edge. - # - # By default, transition edges are set @0 W/K.m i.e., no derating occurs. - # Although structural expansion joints or roof curbs are not as commonly - # encountered as mild transitions, they do constitute significant thermal - # bridges (to consider). Unfortunately, "joints" remain undistinguishable - # from transition edges when parsing OpenStudio geometry. The test here - # illustrates how users can override default "transition" tags via JSON - # input files. - # - # The "tbd_warehouse6.json" file identifies 2x edges in the US DOE - # warehouse prototype building that TBD tags as (mild) transitions by - # default. Both edges concern the "Fine Storage" space (likely as a means - # to ensure surface convexity in the EnergyPlus model). The "ok" PSI set - # holds a single "joint" PSI value of 0.9 W/K per metre (let's assume both - # edges are significant expansion joints, rather than modelling artifacts). - # Each "expansion joint" here represents 4.27m x 0.9 W/K.m (== 3.84 W/K). - # As wall constructions are the same for all 4x walls concerned, each wall - # inherits 1/2 of the extra heat loss from each joint, i.e. 1.92 W/K. - # - # "psis": [ - # { - # "id": "ok", - # "joint": 0.9 - # } - # ], - # "edges": [ - # { - # "psi": "ok", - # "type": "joint", - # "surfaces": [ - # "Fine Storage Front Wall", - # "Fine Storage Office Front Wall" - # ] - # }, - # { - # "psi": "ok", - # "type": "joint", - # "surfaces": [ - # "Fine Storage Left Wall", - # "Fine Storage Office Left Wall" - # ] - # } - # ] - # } + # The model has valid thermostats. + heated = TBD.heatingTemperatureSetpoints?(model) + cooled = TBD.coolingTemperatureSetpoints?(model) + expect(heated).to be true + expect(cooled).to be true - argh = {} - argh[:option ] = "poor (BETBG)" - argh[:io_path ] = File.join(__dir__, "../json/tbd_warehouse6.json") - argh[:schema_path] = File.join(__dir__, "../tbd.schema.json") + plnum = model.getSpaceByName("PLENUM-1") + expect(plnum).to_not be_empty + plnum = plnum.get - json = TBD.process(model, argh) + # The plenum is more akin to an UNCONDITIONED attic (no thermostat). + expect(TBD.plenum?(plnum)).to be false + 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) expect(json).to be_a(Hash) expect(json).to have_key(:io) expect(json).to have_key(:surfaces) @@ -2651,99 +2307,56 @@ expect(TBD.status).to be_zero expect(TBD.logs).to be_empty expect(surfaces).to be_a(Hash) - expect(surfaces.size).to eq(23) + expect(surfaces.size).to eq(40) expect(io).to be_a(Hash) expect(io).to have_key(:edges) - expect(io[:edges].size).to eq(300) - ids = { a: "Office Front Wall", - b: "Office Left Wall", - c: "Fine Storage Roof", - d: "Fine Storage Office Front Wall", - e: "Fine Storage Office Left Wall", - f: "Fine Storage Front Wall", - g: "Fine Storage Left Wall", - h: "Fine Storage Right Wall", - i: "Bulk Storage Roof", - j: "Bulk Storage Rear Wall", - k: "Bulk Storage Left Wall", - l: "Bulk Storage Right Wall" }.freeze + # Plenum "walls" are not derated. + plnum_walls.each do |s| + expect(surfaces).to have_key(s) + expect(surfaces[s][:deratable]).to be false + end - # Testing. - surfaces.each do |id, surface| - expect(ids).to_not have_value(id) unless surface.key?(:edges) + # "Other" ceilings (i.e. those of conditioned spaces, adjacent to plenum + # "floors") are like insulated attic ceilings, and therefore derated. + other_ceilings.each do |s| + expect(surfaces).to have_key(s) + expect(surfaces[s][:deratable]).to be true end - surfaces.each do |id, surface| - next unless surface.key?(:edges) + # There are no above-grade "rimjoists" identified by TBD: + expect(io[:edges].count { |edge| edge[:type] == :rimjoist }).to eq(0) + expect(io[:edges].count { |edge| edge[:type] == :gradeconvex }).to eq(8) + expect(io[:edges].count { |edge| edge[:type] == :parapetconvex }).to eq(4) - expect(ids).to have_value(id) - expect(surface).to have_key(:ratio) - expect(surface).to have_key(:heatloss) - h = surface[:heatloss] - s = model.getSurfaceByName(id) - expect(s).to_not be_empty - s = s.get - expect(s.nameString).to eq(id) - expect(s.isConstructionDefaulted).to be false - expect(s.construction.get.nameString).to include(" tbd") - expect(h).to be_within(TOL).of( 50.20) if id == ids[:a] - expect(h).to be_within(TOL).of( 24.06) if id == ids[:b] - expect(h).to be_within(TOL).of( 87.16) if id == ids[:c] - expect(h).to be_within(TOL).of( 24.53) if id == ids[:d] # 22.61 + 1.92 - expect(h).to be_within(TOL).of( 11.07) if id == ids[:e] # 9.15 + 1.92 - expect(h).to be_within(TOL).of( 28.39) if id == ids[:f] # 26.47 + 1.92 - expect(h).to be_within(TOL).of( 29.11) if id == ids[:g] # 27.19 + 1.92 - expect(h).to be_within(TOL).of( 41.36) if id == ids[:h] - expect(h).to be_within(TOL).of(161.02) if id == ids[:i] - expect(h).to be_within(TOL).of( 62.28) if id == ids[:j] - expect(h).to be_within(TOL).of(117.87) if id == ids[:k] - expect(h).to be_within(TOL).of( 95.77) if id == ids[:l] + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + # Try again, yet first reset the plenum as INDIRECTLYCONDITIONED. + file = File.join(__dir__, "files/osms/in/5Zone_2.osm") + path = OpenStudio::Path.new(file) + model = translator.loadModel(path) + expect(model).to_not be_empty + model = model.get - 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.layers[1].nameString).to include("m tbd") - end - - surfaces.each do |id, surface| - if surface.key?(:ratio) - # ratio = format "%3.1f", surface[:ratio] - # name = id.rjust(15, " ") - # puts "#{name} RSi derated by #{ratio}%" - expect(surface[:ratio]).to be_within(0.2).of(-44.13) if id == ids[:a] - expect(surface[:ratio]).to be_within(0.2).of(-53.02) if id == ids[:b] - expect(surface[:ratio]).to be_within(0.2).of(-15.60) if id == ids[:c] - expect(surface[:ratio]).to be_within(0.2).of(-26.10) if id == ids[:d] - expect(surface[:ratio]).to be_within(0.2).of(-30.86) if id == ids[:e] - expect(surface[:ratio]).to be_within(0.2).of(-21.26) if id == ids[:f] - expect(surface[:ratio]).to be_within(0.2).of(-20.65) if id == ids[:g] - expect(surface[:ratio]).to be_within(0.2).of(-20.51) if id == ids[:h] - expect(surface[:ratio]).to be_within(0.2).of( -7.29) if id == ids[:i] - expect(surface[:ratio]).to be_within(0.2).of(-14.93) if id == ids[:j] - expect(surface[:ratio]).to be_within(0.2).of(-19.02) if id == ids[:k] - expect(surface[:ratio]).to be_within(0.2).of(-15.09) if id == ids[:l] - else - expect(surface[:boundary]).to_not eq("outdoors") - end - end - end - - it "can process seb2.osm (0 W/K per m)" do - translator = OpenStudio::OSVersion::VersionTranslator.new - TBD.clean! + # Ensure the plenum is 'unoccupied', i.e. not part of the total floor area. + plnum = model.getSpaceByName("PLENUM-1") + expect(plnum).to_not be_empty + plnum = plnum.get + expect(plnum.setPartofTotalFloorArea(false)).to be true - file = File.join(__dir__, "files/osms/out/seb2.osm") - path = OpenStudio::Path.new(file) - model = translator.loadModel(path) - expect(model).to_not be_empty - model = model.get + key = "indirectlyconditioned" + val = "SPACE5-1" + expect(plnum.additionalProperties.setFeature(key, val)).to be true + expect(TBD.plenum?(plnum)).to be false + expect(TBD.unconditioned?(plnum)).to be false + expect(TBD.setpoints(plnum)[:heating]).to be_within(TOL).of(22.20) + expect(TBD.setpoints(plnum)[:cooling]).to be_within(TOL).of(23.90) + expect(TBD.status).to be_zero - argh = { option: "(non thermal bridging)" } + file = File.join(__dir__, "files/osms/out/z5.osm") + model.save(file, true) - 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) @@ -2752,36 +2365,72 @@ expect(TBD.status).to be_zero expect(TBD.logs).to be_empty expect(surfaces).to be_a(Hash) - expect(surfaces.size).to eq(56) + expect(surfaces.size).to eq(40) expect(io).to be_a(Hash) expect(io).to have_key(:edges) - expect(io[:edges].size).to eq(106) - surfaces.each do |id, surface| - expect(surface).to have_key(:conditioned) - next unless surface[:conditioned] + # Plenum "walls" are now derated. + plnum_walls.each do |s| + expect(surfaces).to have_key(s) + expect(surfaces[s][:deratable]).to be true + end - expect(surface).to have_key(:heating) - expect(surface).to have_key(:cooling) + # "Other" ceilings (i.e. those of conditioned spaces, adjacent to plenum + # "floors") are now like uninsulated suspended ceilings (no longer derated). + other_ceilings.each do |s| + expect(surfaces).to have_key(s) + expect(surfaces[s][:deratable]).to be false end - # Since all PSI values = 0, we're not expecting any derated surfaces - surfaces.values.each { |surface| expect(surface).to_not have_key(:ratio) } - end + # Prior to v3.4.0, plenum floors would have been tagged as "rimjoists". No + # longer the case ("ceilings" are caught earlier in the process). + expect(io[:edges].count { |edge| edge[:type] == :ceiling }).to eq(4) + expect(io[:edges].count { |edge| edge[:type] == :rimjoist }).to eq(0) + expect(io[:edges].count { |edge| edge[:type] == :gradeconvex }).to eq(8) + expect(io[:edges].count { |edge| edge[:type] == :parapetconvex }).to eq(4) - it "can process seb2.osm (0 W/K per m) with JSON" do - translator = OpenStudio::OSVersion::VersionTranslator.new - TBD.clean! + # There are (very) rare cases of INDIRECTLYCONDITIONED technical spaces + # (above occupied spaces) that have structural "floors" (not e.g. suspended + # ceiling tiles), supporting significant static and dynamic loads (e.g. + # Louis Kahn's Salk Institute). Yet for the vast majority of cases (e.g. + # return air plenums), we see simple suspended ceilings. Their perimeter + # edges do not thermally bridge (or derate) insulated building envelopes. + # + # Prior to v3.4.0, we initially retained a laissez-faire approach with TBD + # regarding floors of INDIRECTLYCONDITIONED spaces (like plenums). Indeed, + # many (older?) OpenStudio models have plenum floors with 'reset' surface + # types ("RoofCeiling"), which was sufficient for TBD to not tag such edges + # as "rimjoists", i.e. intermediate (structural) floor slabs. Sure, TBD + # users could always override this default behaviour by specifying spacetype + # -specific PSI factor sets (JSON inputs), with "rimjoists" of 0 W/K per + # meter. Yet these workarounds necessarily implied additional steps for the + # vast majority of TBD users. As of v3.4.0, the default automated TBD + # outcome is to tag plenum "floors" as "ceilings" (no additional steps). + # + # The flip side is that additional consideration may be required for less + # common cases involving plenums. Take for instance underfloor air supply + # plenums. The carpeted floors building occupants actually walk on are not + # structural concrete slabs (the perimeter edges of which would constitute + # common thermal bridges, i.e. "rimjoists"). By default, TBD will now tag + # the raised floor as a structural "floor" (with associated thermal + # bridging) and instead tag the actual structural slab as "ceiling". + # Although this doesn't sound OK initially, this works out just fine for + # most cases: the "rimjoist" edge may not line up perfectly (vertically), + # but there remains only one per surface (a similar outcome to 'offset' + # masonry shelf angles). Users are always free to curtomize TBD (via + # JSON input) if needed. - file = File.join(__dir__, "files/osms/out/seb2.osm") + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + # Test a custom non-0 "ceiling" PSI-factor. + file = File.join(__dir__, "files/osms/out/z5.osm") path = OpenStudio::Path.new(file) model = translator.loadModel(path) expect(model).to_not be_empty model = model.get argh = {} - argh[:option ] = "(non thermal bridging)" - argh[:io_path ] = File.join(__dir__, "../json/tbd_seb.json") + argh[:option ] = "uncompliant (Quebec)" + argh[:io_path ] = File.join(__dir__, "../json/tbd_z5.json") argh[:schema_path] = File.join(__dir__, "../tbd.schema.json") json = TBD.process(model, argh) @@ -2793,729 +2442,698 @@ expect(TBD.status).to be_zero expect(TBD.logs).to be_empty expect(surfaces).to be_a(Hash) - expect(surfaces.size).to eq(56) + expect(surfaces.size).to eq(40) expect(io).to be_a(Hash) expect(io).to have_key(:edges) - expect(io[:edges].size).to eq(106) - # As the :building PSI set on file remains "(non thermal bridging)", one - # should not expect differences in results, i.e. derating shouldn't occur. - surfaces.values.each { |surface| expect(surface).to_not have_key(:ratio) } - end - - it "can process seb2.osm (0 W/K per m) with JSON (non-0)" do - translator = OpenStudio::OSVersion::VersionTranslator.new - TBD.clean! + # Plenum "walls" are (still) derated. + plnum_walls.each do |s| + expect(surfaces).to have_key(s) + expect(surfaces[s][:deratable]).to be true + end - file = File.join(__dir__, "files/osms/out/seb2.osm") - path = OpenStudio::Path.new(file) - model = translator.loadModel(path) - expect(model).to_not be_empty - model = model.get + # "Other" ceilings (i.e. those of conditioned spaces, adjacent to plenum + # "floors") are (still) no longer derated. + other_ceilings.each do |s| + expect(surfaces).to have_key(s) + expect(surfaces[s][:deratable]).to be false + end - # Consider the plenum as UNCONDITIONED. - plnum = model.getSpaceByName("Level 0 Ceiling Plenum") - expect(plnum).to_not be_empty - plnum = plnum.get - expect(TBD.unconditioned?(plnum)).to be false + io[:edges].select { |edge| edge[:type] == :ceiling }.each do |edge| + expect(edge[:psi]).to eq("salk") + end - key = "space_conditioning_category" - val = "Unconditioned" - expect(plnum.additionalProperties.hasFeature(key)).to be false - expect(plnum.additionalProperties.setFeature(key, val)).to be true - expect(TBD.plenum?(plnum)).to be true # fyi, still has "plenum" spacetype - expect(TBD.unconditioned?(plnum)).to be true # ... more reliable - expect(TBD.setpoints(plnum)[:heating]).to be_nil - expect(TBD.setpoints(plnum)[:cooling]).to be_nil - expect(TBD.status).to be_zero + expect(io[:edges].count { |edge| edge[:type] == :ceiling }).to eq(4) + expect(io[:edges].count { |edge| edge[:type] == :rimjoist }).to eq(0) + expect(io[:edges].count { |edge| edge[:type] == :gradeconvex }).to eq(8) + expect(io[:edges].count { |edge| edge[:type] == :parapetconvex }).to eq(4) - argh = {} - argh[:option ] = "(non thermal bridging)" - argh[:io_path ] = File.join(__dir__, "../json/tbd_seb_n0.json") - argh[:schema_path] = File.join(__dir__, "../tbd.schema.json") + out = JSON.pretty_generate(io) + file = File.join(__dir__, "../json/tbd_z5.out.json") + File.open(file, "w") { |f| f.puts out } - 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.status).to be_zero - expect(TBD.logs).to be_empty - expect(surfaces).to be_a(Hash) - expect(surfaces.size).to eq(56) - expect(io).to be_a(Hash) - expect(io).to have_key(:edges) - expect(io[:edges].size).to eq(80) # 106 if plenum were INDIRECTLYCONDITIONED - ids = { a: "Entryway Wall 4", - b: "Entryway Wall 5", - c: "Entryway Wall 6", - d: "Entry way DroppedCeiling", - e: "Utility1 Wall 1", - f: "Utility1 Wall 5", - g: "Utility 1 DroppedCeiling", - h: "Smalloffice 1 Wall 1", - i: "Smalloffice 1 Wall 2", - j: "Smalloffice 1 Wall 6", - k: "Small office 1 DroppedCeiling", - l: "Openarea 1 Wall 3", - m: "Openarea 1 Wall 4", - n: "Openarea 1 Wall 5", - o: "Openarea 1 Wall 6", - p: "Openarea 1 Wall 7", - q: "Open area 1 DroppedCeiling" - }.freeze + # --- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- --- # + # The following variations of the 'FullServiceRestaurant' (v3.2.1) are + # snapshots of incremental development of the same model. For each step, + # the tests illustrate how TBD ends up considering the unoccupied space + # (below roof) and how simple variable changes allow users to switch from + # UNCONDITIONED to INDIRECTLYCONDITIONED (or vice versa). + unless OpenStudio.openStudioVersion.split(".").join.to_i < 321 + TBD.clean! - # The :building PSI set on file "compliant" supersedes the argh[:option] - # "(non thermal bridging)", so one should expect differences in results, - # i.e. derating should occur. The next 2 tests: - # 1. setting both argh[:option] & file :building to "compliant" - # 2. setting argh[:option] to "compliant" + removing :building from file - # ... all 3x cases should yield the same results. - surfaces.each do |id, surface| - expect(ids).to have_value(id) if surface.key?(:edges) - end + # Unaltered template OpenStudio model: + # - constructions: NO + # - setpoints : NO + # - HVAC : NO + file = File.join(__dir__, "files/osms/in/resto1.osm") + path = OpenStudio::Path.new(file) + model = translator.loadModel(path) + expect(model).to_not be_empty + model = model.get + attic = model.getSpaceByName("Attic") + expect(attic).to_not be_empty + attic = attic.get - surfaces.each do |id, surface| - next unless surface.key?(:edges) + expect(model.getConstructions).to be_empty + heated = TBD.heatingTemperatureSetpoints?(model) + cooled = TBD.coolingTemperatureSetpoints?(model) + expect(heated).to be false + expect(cooled).to be false - expect(ids).to have_value(id) - expect(surface).to have_key(:heatloss) - expect(surface).to have_key(:ratio) - h = surface[:heatloss] - s = model.getSurfaceByName(id) - expect(s).to_not be_empty - s = s.get - expect(s.nameString).to eq(id) - expect(s.isConstructionDefaulted).to be false - expect(s.construction.get.nameString).to include(" tbd") - expect(h).to be_within(TOL).of( 3.62) if id == ids[:a] - expect(h).to be_within(TOL).of( 6.28) if id == ids[:b] - expect(h).to be_within(TOL).of( 2.62) if id == ids[:c] - expect(h).to be_within(TOL).of( 0.17) if id == ids[:d] - expect(h).to be_within(TOL).of( 7.13) if id == ids[:e] - expect(h).to be_within(TOL).of( 7.09) if id == ids[:f] - expect(h).to be_within(TOL).of( 0.20) if id == ids[:g] - expect(h).to be_within(TOL).of( 7.94) if id == ids[:h] - expect(h).to be_within(TOL).of( 5.17) if id == ids[:i] - expect(h).to be_within(TOL).of( 5.01) if id == ids[:j] - expect(h).to be_within(TOL).of( 0.22) if id == ids[:k] - expect(h).to be_within(TOL).of( 2.47) if id == ids[:l] - expect(h).to be_within(TOL).of( 3.11) if id == ids[:m] - expect(h).to be_within(TOL).of( 4.43) if id == ids[:n] - expect(h).to be_within(TOL).of( 3.35) if id == ids[:o] - expect(h).to be_within(TOL).of( 2.12) if id == ids[:p] - expect(h).to be_within(TOL).of( 0.31) if id == ids[:q] + 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) + io = json[:io ] + surfaces = json[:surfaces] + expect(TBD.error?).to be true + expect(TBD.logs).to_not be_empty + expect(surfaces).to be_a(Hash) + expect(surfaces.size).to eq(18) + expect(io).to be_a(Hash) + expect(io).to_not have_key(:edges) - c = s.construction - expect(c).to_not be_empty - c = c.get.to_LayeredConstruction - expect(c).to_not be_empty - c = c.get - i = 0 - i = 2 if s.outsideBoundaryCondition.downcase == "outdoors" - expect(c.layers[i].nameString).to include("m tbd") - end + TBD.logs.each do |log| + expect(log[:message]).to include("missing").or include("layer?") + end - surfaces.each do |id, surface| - expect(surface).to have_key(:filmRSI) + # As the model doesn't hold any constructions, TBD skips over any + # derating steps. Yet despite the OpenStudio model not holding ANY valid + # heating or cooling setpoints, ALL spaces are considered CONDITIONED. + surfaces.values.each do |surface| + expect(surface).to be_a(Hash) + expect(surface).to have_key(:space) + expect(surface).to have_key(:stype) # spacetype + expect(surface).to have_key(:conditioned) + expect(surface).to have_key(:deratable) + expect(surface).to_not have_key(:construction) + expect(surface[:conditioned]).to be true # even attic + expect(surface[:deratable ]).to be false # no constructions! + end - if surface.key?(:ratio) - expect(surface[:ratio]).to be_within(0.1).of(-28.93) if id == ids[:a] - expect(surface[:ratio]).to be_within(0.1).of(-26.61) if id == ids[:b] - expect(surface[:ratio]).to be_within(0.1).of(-25.82) if id == ids[:c] - expect(surface[:ratio]).to be_within(0.1).of( -0.06) if id == ids[:d] - expect(surface[:ratio]).to be_within(0.1).of(-27.14) if id == ids[:e] - expect(surface[:ratio]).to be_within(0.1).of(-27.18) if id == ids[:f] - expect(surface[:ratio]).to be_within(0.1).of( -0.05) if id == ids[:g] - expect(surface[:ratio]).to be_within(0.1).of(-32.40) if id == ids[:h] - expect(surface[:ratio]).to be_within(0.1).of(-32.58) if id == ids[:i] - expect(surface[:ratio]).to be_within(0.1).of(-32.77) if id == ids[:j] - expect(surface[:ratio]).to be_within(0.1).of( -0.05) if id == ids[:k] - expect(surface[:ratio]).to be_within(0.1).of(-18.14) if id == ids[:l] - expect(surface[:ratio]).to be_within(0.1).of(-21.97) if id == ids[:m] - expect(surface[:ratio]).to be_within(0.1).of(-18.77) if id == ids[:n] - expect(surface[:ratio]).to be_within(0.1).of(-21.14) if id == ids[:o] - expect(surface[:ratio]).to be_within(0.1).of(-19.10) if id == ids[:p] - expect(surface[:ratio]).to be_within(0.1).of( -0.04) if id == ids[:q] + # OSut correctly report spaces here as UNCONDITIONED. Tagging ALL spaces + # instead as CONDITIONED in such (rare) cases is unique to TBD. + id = "attic-floor-dinning" + expect(surfaces).to have_key(id) - next unless id == ids[:a] + attic = surfaces[id][:space] + heat = TBD.setpoints(attic)[:heating] + cool = TBD.setpoints(attic)[:cooling] + expect(TBD.unconditioned?(attic)).to be true + expect(heat).to be_nil + expect(cool).to be_nil + expect(attic.partofTotalFloorArea).to be false + expect(TBD.plenum?(attic)).to be false - s = model.getSurfaceByName(id) - expect(s).to_not be_empty - s = s.get - expect(s.nameString).to eq(id) - expect(s.surfaceType).to eq("Wall") - expect(s.isConstructionDefaulted).to be false - c = s.construction.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) - expect(c.layers[2].nameString).to include("m tbd") - expect(c.layers[2].to_StandardOpaqueMaterial).to_not be_empty - m = c.layers[2].to_StandardOpaqueMaterial.get - initial_R = surface[:filmRSI] + 2.4674 - derated_R = surface[:filmRSI] + 0.9931 - derated_R += m.thickness / m.thermalConductivity + # - ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- - # + # A more developed 'FullServiceRestaurant' (midway BTAP generation): + # - constructions: YES + # - setpoints : YES + # - HVAC : NO + TBD.clean! - ratio = -(initial_R - derated_R) * 100 / initial_R - expect(ratio).to be_within(1).of(surfaces[id][:ratio]) - else - if surface[:boundary] == "outdoors" - expect(surface[:conditioned]).to be false - end - end - end - end + file = File.join(__dir__, "files/osms/in/resto2.osm") + path = OpenStudio::Path.new(file) + model = translator.loadModel(path) + expect(model).to_not be_empty + model = model.get - it "can process seb2.osm (0 W/K per m) with JSON (non-0) 2" do - translator = OpenStudio::OSVersion::VersionTranslator.new - TBD.clean! + # BTAP-set (interior) ceiling constructions (i.e. attic/plenum floors) + # are characteristic of occupied floors (e.g. carpet over 4" concrete + # slab). Clone/assign insulated roof construction to plenum/attic floors. + set = model.getBuilding.defaultConstructionSet + expect(set).to_not be_empty + set = set.get - file = File.join(__dir__, "files/osms/out/seb2.osm") - path = OpenStudio::Path.new(file) - model = translator.loadModel(path) - expect(model).to_not be_empty - model = model.get + interiors = set.defaultInteriorSurfaceConstructions + exteriors = set.defaultExteriorSurfaceConstructions + expect(interiors).to_not be_empty + expect(exteriors).to_not be_empty + interiors = interiors.get + exteriors = exteriors.get + roofs = exteriors.roofCeilingConstruction + expect(roofs).to_not be_empty + roofs = roofs.get + insulated = roofs.clone(model).to_LayeredConstruction + expect(insulated).to_not be_empty + insulated = insulated.get + insulated.setName("Insulated Attic Floors") + expect(interiors.setRoofCeilingConstruction(insulated)).to be true - # Consider the plenum as UNCONDITIONED. - plnum = model.getSpaceByName("Level 0 Ceiling Plenum") - expect(plnum).to_not be_empty - plnum = plnum.get - expect(TBD.unconditioned?(plnum)).to be false + # Validate re-assignment via individual attic floor surfaces. + construction = nil + ceilings = [] - key = "space_conditioning_category" - val = "Unconditioned" - expect(plnum.additionalProperties.hasFeature(key)).to be false - expect(plnum.additionalProperties.setFeature(key, val)).to be true - expect(TBD.plenum?(plnum)).to be true - expect(TBD.unconditioned?(plnum)).to be true - expect(TBD.setpoints(plnum)[:heating]).to be_nil - expect(TBD.setpoints(plnum)[:cooling]).to be_nil - expect(TBD.status).to be_zero + model.getSurfaces.each do |s| + next unless s.surfaceType == "RoofCeiling" + next unless s.outsideBoundaryCondition == "Surface" - # Setting both PSI option & file :building to "compliant" - argh = {} - argh[:option ] = "compliant" # instead of "(non thermal bridging)" - argh[:io_path ] = File.join(__dir__, "../json/tbd_seb_n0.json") - argh[:schema_path] = File.join(__dir__, "../tbd.schema.json") + ceilings << s.nameString + c = s.construction + expect(c).to_not be_empty + c = c.get.to_LayeredConstruction + expect(c).to_not be_empty + c = c.get - 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.status).to be_zero - expect(TBD.logs).to be_empty - expect(surfaces).to be_a(Hash) - expect(surfaces.size).to eq(56) - expect(io).to be_a(Hash) - expect(io).to have_key(:edges) - expect(io[:edges].size).to eq(80) # 106 if plnum INDIRECTLYCONDITIONED + 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) - ids = { a: "Entryway Wall 4", - b: "Entryway Wall 5", - c: "Entryway Wall 6", - d: "Entry way DroppedCeiling", - e: "Utility1 Wall 1", - f: "Utility1 Wall 5", - g: "Utility 1 DroppedCeiling", - h: "Smalloffice 1 Wall 1", - i: "Smalloffice 1 Wall 2", - j: "Smalloffice 1 Wall 6", - k: "Small office 1 DroppedCeiling", - l: "Openarea 1 Wall 3", - m: "Openarea 1 Wall 4", - n: "Openarea 1 Wall 5", - o: "Openarea 1 Wall 6", - p: "Openarea 1 Wall 7", - q: "Open area 1 DroppedCeiling" - }.freeze + construction = c if construction.nil? + expect(c).to eq(construction) + end - surfaces.each do |id, surface| - expect(ids).to_not have_value(id) unless surface.key?(:edges) - end + expect(construction ).to eq(insulated) + expect(construction.getNetArea ).to be_within(TOL).of(511.15) + expect(ceilings.size ).to eq(2) + expect(construction.layers.size).to eq(2) + expect(construction.nameString ).to eq("Insulated Attic Floors") + expect(model.getConstructions).to_not be_empty + heated = TBD.heatingTemperatureSetpoints?(model) + cooled = TBD.coolingTemperatureSetpoints?(model) + expect(heated).to be true + expect(cooled).to be true - surfaces.each do |id, surface| - next unless surface.key?(:edges) + attic = model.getSpaceByName("attic") + expect(attic).to_not be_empty + attic = attic.get - expect(ids).to have_value(id) - expect(surface).to have_key(:heatloss) - expect(surface).to have_key(:ratio) - h = surface[:heatloss] - s = model.getSurfaceByName(id) - expect(s).to_not be_empty - s = s.get - expect(s.nameString).to eq(id) - expect(s.isConstructionDefaulted).to be false - expect(s.construction.get.nameString).to include(" tbd") - expect(h).to be_within(TOL).of( 3.62) if id == ids[:a] - expect(h).to be_within(TOL).of( 6.28) if id == ids[:b] - expect(h).to be_within(TOL).of( 2.62) if id == ids[:c] - expect(h).to be_within(TOL).of( 0.17) if id == ids[:d] - expect(h).to be_within(TOL).of( 7.13) if id == ids[:e] - expect(h).to be_within(TOL).of( 7.09) if id == ids[:f] - expect(h).to be_within(TOL).of( 0.20) if id == ids[:g] - expect(h).to be_within(TOL).of( 7.94) if id == ids[:h] - expect(h).to be_within(TOL).of( 5.17) if id == ids[:i] - expect(h).to be_within(TOL).of( 5.01) if id == ids[:j] - expect(h).to be_within(TOL).of( 0.22) if id == ids[:k] - expect(h).to be_within(TOL).of( 2.47) if id == ids[:l] - expect(h).to be_within(TOL).of( 3.11) if id == ids[:m] - expect(h).to be_within(TOL).of( 4.43) if id == ids[:n] - expect(h).to be_within(TOL).of( 3.35) if id == ids[:o] - expect(h).to be_within(TOL).of( 2.12) if id == ids[:p] - expect(h).to be_within(TOL).of( 0.31) if id == ids[:q] + expect(attic.partofTotalFloorArea).to be false + heat = TBD.setpoints(attic)[:heating] + cool = TBD.setpoints(attic)[:cooling] + expect(heat).to be_nil + expect(cool).to be_nil - c = s.construction - expect(c).to_not be_empty - c = c.get.to_LayeredConstruction - expect(c).to_not be_empty - c = c.get - i = 0 - i = 2 if s.outsideBoundaryCondition.downcase == "outdoors" - expect(c.layers[i].nameString).to include("m tbd") - end + expect(TBD.plenum?(attic)).to be false + expect(attic.partofTotalFloorArea).to be false + expect(attic.thermalZone).to_not be_empty + zone = attic.thermalZone.get + expect(zone.isPlenum).to be false - surfaces.each do |id, surface| - expect(surface).to have_key(:filmRSI) + tstat = zone.thermostat + expect(tstat).to_not be_empty + tstat = tstat.get + expect(tstat.to_ThermostatSetpointDualSetpoint).to_not be_empty + tstat = tstat.to_ThermostatSetpointDualSetpoint.get + expect(tstat.getHeatingSchedule).to be_empty + expect(tstat.getCoolingSchedule).to be_empty - if surface.key?(:ratio) - expect(surface[:ratio]).to be_within(0.1).of(-28.93) if id == ids[:a] - expect(surface[:ratio]).to be_within(0.1).of(-26.61) if id == ids[:b] - expect(surface[:ratio]).to be_within(0.1).of(-25.82) if id == ids[:c] - expect(surface[:ratio]).to be_within(0.1).of( -0.06) if id == ids[:d] - expect(surface[:ratio]).to be_within(0.1).of(-27.14) if id == ids[:e] - expect(surface[:ratio]).to be_within(0.1).of(-27.18) if id == ids[:f] - expect(surface[:ratio]).to be_within(0.1).of( -0.05) if id == ids[:g] - expect(surface[:ratio]).to be_within(0.1).of(-32.40) if id == ids[:h] - expect(surface[:ratio]).to be_within(0.1).of(-32.58) if id == ids[:i] - expect(surface[:ratio]).to be_within(0.1).of(-32.77) if id == ids[:j] - expect(surface[:ratio]).to be_within(0.1).of( -0.05) if id == ids[:k] - expect(surface[:ratio]).to be_within(0.1).of(-18.14) if id == ids[:l] - expect(surface[:ratio]).to be_within(0.1).of(-21.97) if id == ids[:m] - expect(surface[:ratio]).to be_within(0.1).of(-18.77) if id == ids[:n] - expect(surface[:ratio]).to be_within(0.1).of(-21.14) if id == ids[:o] - expect(surface[:ratio]).to be_within(0.1).of(-19.10) if id == ids[:p] - expect(surface[:ratio]).to be_within(0.1).of( -0.04) if id == ids[:q] + heat = TBD.maxHeatScheduledSetpoint(zone) + cool = TBD.minCoolScheduledSetpoint(zone) + expect(heat).to_not be_nil + expect(cool).to_not be_nil + expect(heat).to be_a(Hash) + expect(cool).to be_a(Hash) + expect(heat).to have_key(:spt) + expect(cool).to have_key(:spt) + expect(heat).to have_key(:dual) + expect(cool).to have_key(:dual) + expect(heat[:spt]).to be_nil + expect(cool[:spt]).to be_nil + expect(heat[:dual]).to be false + expect(cool[:dual]).to be false - next unless id == ids[:a] + # The unoccupied space does not reference valid heating and/or cooling + # temperature setpoint objects, and is therefore considered + # UNCONDITIONED. Save for next iteration. + file = File.join(__dir__, "files/osms/out/resto2a.osm") + model.save(file, true) - s = model.getSurfaceByName(id) + argh = {} + argh[:option ] = "efficient (BETBG)" + argh[:uprate_roofs] = true + argh[:roof_option ] = "ALL roof constructions" + argh[:roof_ut ] = 0.138 # NECB CZ7 2017 (RSi 7.25 / R41) + + 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.status).to be_zero + expect(TBD.logs).to be_empty + expect(surfaces).to be_a(Hash) + expect(surfaces.size).to eq(18) + expect(io).to be_a(Hash) + expect(io).to have_key(:edges) + expect(io[:edges].size).to eq(31) + + expect(argh).to_not have_key(:wall_uo) + expect(argh).to have_key(:roof_uo) + expect(argh[:roof_uo]).to be_within(TOL).of(0.119) + + # Validate ceiling surfaces (both insulated & uninsulated). + ua = 0.0 + a = 0.0 + + 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) + expect(surface).to have_key(:type) + next if surface[:ground] + next unless surface[:type ] == :ceiling + + # Sloped attic roof surfaces ignored by TBD. + id = surface[:construction].nameString + expect(nom).to include("-roof" ) unless surface[:deratable] + expect(id ).to include("BTAP-Ext-") unless surface[:deratable] + expect(surface[:conditioned] ).to be false unless surface[:deratable] + next unless surface[:deratable] + next unless surface.key?(:heatloss) + + # Leaves only insulated attic ceilings. + expect(id).to eq("Insulated Attic Floors") # original construction + s = model.getSurfaceByName(nom) expect(s).to_not be_empty s = s.get - expect(s.nameString).to eq(id) - expect(s.surfaceType).to eq("Wall") - expect(s.isConstructionDefaulted).to be false - c = s.construction.get.to_LayeredConstruction + 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) - expect(c.layers[2].nameString).to include("m tbd") - expect(c.layers[2].to_StandardOpaqueMaterial).to_not be_empty - m = c.layers[2].to_StandardOpaqueMaterial.get - initial_R = surface[:filmRSI] + 2.4674 - derated_R = surface[:filmRSI] + 0.9931 - derated_R += m.thickness / m.thermalConductivity - - ratio = -(initial_R - derated_R) * 100 / initial_R - expect(ratio).to be_within(1).of(surfaces[id][:ratio]) - else - if surface[:boundary] == "outdoors" - expect(surface[:conditioned]).to be false - end + expect(c.nameString).to include("c tbd") # TBD-derated + a += surface[:net] + ua += 1 / TBD.rsi(c, surface[:filmRSI]) * surface[:net] end - end - end - it "can process seb2.osm (0 W/K per m) with JSON (non-0) 3" do - translator = OpenStudio::OSVersion::VersionTranslator.new - TBD.clean! + expect(ua / a).to be_within(TOL).of(argh[:roof_ut]) - file = File.join(__dir__, "files/osms/out/seb2.osm") - path = OpenStudio::Path.new(file) - model = translator.loadModel(path) - expect(model).to_not be_empty - model = model.get - # Consider the plenum as UNCONDITIONED. - plnum = model.getSpaceByName("Level 0 Ceiling Plenum") - expect(plnum).to_not be_empty - plnum = plnum.get - expect(TBD.unconditioned?(plnum)).to be false + # - ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- - # + # Altered model from previous iteration, yet no uprating this round. + # - constructions: YES + # - setpoints : YES + # - HVAC : NO + TBD.clean! - key = "space_conditioning_category" - val = "Unconditioned" - expect(plnum.additionalProperties.hasFeature(key)).to be false - expect(plnum.additionalProperties.setFeature(key, val)).to be true - expect(TBD.plenum?(plnum)).to be true - expect(TBD.unconditioned?(plnum)).to be true - expect(TBD.setpoints(plnum)[:heating]).to be_nil - expect(TBD.setpoints(plnum)[:cooling]).to be_nil - expect(TBD.status).to be_zero + file = File.join(__dir__, "files/osms/out/resto2a.osm") + path = OpenStudio::Path.new(file) + model = translator.loadModel(path) + expect(model).to_not be_empty + model = model.get + heated = TBD.heatingTemperatureSetpoints?(model) + cooled = TBD.coolingTemperatureSetpoints?(model) + expect(model.getConstructions).to_not be_empty + expect(heated).to be true + expect(cooled).to be true - # Setting PSI set to "compliant" while removing the :building from file. - argh = {} - argh[:option ] = "compliant" # instead of "(non thermal bridging)" - argh[:io_path ] = File.join(__dir__, "../json/tbd_seb_n1.json") - argh[:schema_path] = File.join(__dir__, "../tbd.schema.json") + # In this iteration, ensure the unoccupied space is considered as an + # INDIRECTLYCONDITIONED plenum (instead of an UNCONDITIONED attic), by + # temporarily adding a heating dual setpoint schedule object to its zone + # thermostat (yet without valid scheduled temperatures). + attic = model.getSpaceByName("attic") + expect(attic).to_not be_empty + attic = attic.get + expect(attic.partofTotalFloorArea).to be false + expect(attic.thermalZone).to_not be_empty + zone = attic.thermalZone.get + expect(zone.isPlenum).to be false + tstat = zone.thermostat + expect(tstat).to_not be_empty + tstat = tstat.get - 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.status).to be_zero - expect(TBD.logs).to be_empty - expect(surfaces).to be_a(Hash) - expect(surfaces.size).to eq(56) - expect(io).to be_a(Hash) - expect(io).to have_key(:edges) - expect(io[:edges].size).to eq(80) # 106 if plnum INDIRECTLYCONDITIONED + expect(tstat.to_ThermostatSetpointDualSetpoint).to_not be_empty + tstat = tstat.to_ThermostatSetpointDualSetpoint.get - ids = { a: "Entryway Wall 4", - b: "Entryway Wall 5", - c: "Entryway Wall 6", - d: "Entry way DroppedCeiling", - e: "Utility1 Wall 1", - f: "Utility1 Wall 5", - g: "Utility 1 DroppedCeiling", - h: "Smalloffice 1 Wall 1", - i: "Smalloffice 1 Wall 2", - j: "Smalloffice 1 Wall 6", - k: "Small office 1 DroppedCeiling", - l: "Openarea 1 Wall 3", - m: "Openarea 1 Wall 4", - n: "Openarea 1 Wall 5", - o: "Openarea 1 Wall 6", - p: "Openarea 1 Wall 7", - q: "Open area 1 DroppedCeiling" - }.freeze + # Before the addition. + expect(tstat.getHeatingSchedule).to be_empty + expect(tstat.getCoolingSchedule).to be_empty - surfaces.each do |id, surface| - expect(ids).to_not have_value(id) unless surface.key?(:edges) - end + heat = TBD.maxHeatScheduledSetpoint(zone) + cool = TBD.minCoolScheduledSetpoint(zone) + stpts = TBD.setpoints(attic) - surfaces.each do |id, surface| - next unless surface.key?(:edges) + expect(heat).to_not be_nil + expect(cool).to_not be_nil + expect(heat).to be_a(Hash) + expect(cool).to be_a(Hash) + expect(heat).to have_key(:spt) + expect(cool).to have_key(:spt) + expect(heat).to have_key(:dual) + expect(cool).to have_key(:dual) + expect(heat[:spt]).to be_nil + expect(cool[:spt]).to be_nil + expect(heat[:dual]).to be false + expect(cool[:dual]).to be false - expect(ids).to have_value(id) - expect(surface).to have_key(:ratio) - expect(surface).to have_key(:heatloss) - h = surface[:heatloss] - s = model.getSurfaceByName(id) - expect(s).to_not be_empty - s = s.get - expect(s.nameString).to eq(id) - expect(s.isConstructionDefaulted).to be false - expect(s.construction.get.nameString).to include(" tbd") - expect(h).to be_within(TOL).of( 3.62) if id == ids[:a] - expect(h).to be_within(TOL).of( 6.28) if id == ids[:b] - expect(h).to be_within(TOL).of( 2.62) if id == ids[:c] - expect(h).to be_within(TOL).of( 0.17) if id == ids[:d] - expect(h).to be_within(TOL).of( 7.13) if id == ids[:e] - expect(h).to be_within(TOL).of( 7.09) if id == ids[:f] - expect(h).to be_within(TOL).of( 0.20) if id == ids[:g] - expect(h).to be_within(TOL).of( 7.94) if id == ids[:h] - expect(h).to be_within(TOL).of( 5.17) if id == ids[:i] - expect(h).to be_within(TOL).of( 5.01) if id == ids[:j] - expect(h).to be_within(TOL).of( 0.22) if id == ids[:k] - expect(h).to be_within(TOL).of( 2.47) if id == ids[:l] - expect(h).to be_within(TOL).of( 3.11) if id == ids[:m] - expect(h).to be_within(TOL).of( 4.43) if id == ids[:n] - expect(h).to be_within(TOL).of( 3.35) if id == ids[:o] - expect(h).to be_within(TOL).of( 2.12) if id == ids[:p] - expect(h).to be_within(TOL).of( 0.31) if id == ids[:q] + expect(stpts[:heating]).to be_nil + expect(stpts[:cooling]).to be_nil + expect(TBD.unconditioned?(attic)).to be true + expect(TBD.plenum?(attic)).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 - i = 0 - i = 2 if s.outsideBoundaryCondition.downcase == "outdoors" - expect(c.layers[i].nameString).to include("m tbd") - end + # Add a dual setpoint temperature schedule. + identifier = "TEMPORARY attic setpoint schedule" - surfaces.each do |id, surface| - expect(surface).to have_key(:filmRSI) + sched = OpenStudio::Model::ScheduleCompact.new(model) + sched.setName(identifier) + expect(sched.constantValue).to be_empty + expect(tstat.setHeatingSetpointTemperatureSchedule(sched)).to be true - if surface.key?(:ratio) - # ratio = format "%3.1f", surface[:ratio] - # name = id.rjust(15, " ") - # puts "#{name} RSi derated by #{ratio}%" - expect(surface[:ratio]).to be_within(0.1).of(-28.93) if id == ids[:a] - expect(surface[:ratio]).to be_within(0.1).of(-26.61) if id == ids[:b] - expect(surface[:ratio]).to be_within(0.1).of(-25.82) if id == ids[:c] - expect(surface[:ratio]).to be_within(0.1).of( -0.06) if id == ids[:d] - expect(surface[:ratio]).to be_within(0.1).of(-27.14) if id == ids[:e] - expect(surface[:ratio]).to be_within(0.1).of(-27.18) if id == ids[:f] - expect(surface[:ratio]).to be_within(0.1).of( -0.05) if id == ids[:g] - expect(surface[:ratio]).to be_within(0.1).of(-32.40) if id == ids[:h] - expect(surface[:ratio]).to be_within(0.1).of(-32.58) if id == ids[:i] - expect(surface[:ratio]).to be_within(0.1).of(-32.77) if id == ids[:j] - expect(surface[:ratio]).to be_within(0.1).of( -0.05) if id == ids[:k] - expect(surface[:ratio]).to be_within(0.1).of(-18.14) if id == ids[:l] - expect(surface[:ratio]).to be_within(0.1).of(-21.97) if id == ids[:m] - expect(surface[:ratio]).to be_within(0.1).of(-18.77) if id == ids[:n] - expect(surface[:ratio]).to be_within(0.1).of(-21.14) if id == ids[:o] - expect(surface[:ratio]).to be_within(0.1).of(-19.10) if id == ids[:p] - expect(surface[:ratio]).to be_within(0.1).of( -0.04) if id == ids[:q] + # After the addition. + expect(tstat.getHeatingSchedule).to_not be_empty + expect(tstat.getCoolingSchedule).to be_empty + heat = TBD.maxHeatScheduledSetpoint(zone) + stpts = TBD.setpoints(attic) - next unless id == ids[:a] + expect(heat).to_not be_nil + expect(heat).to be_a(Hash) + expect(heat).to have_key(:spt) + expect(heat).to have_key(:dual) + expect(heat[:spt ]).to be_nil + expect(heat[:dual]).to be true - s = model.getSurfaceByName(id) - expect(s).to_not be_empty - s = s.get - expect(s.nameString).to eq(id) - expect(s.surfaceType).to eq("Wall") - expect(s.isConstructionDefaulted).to be false - c = s.construction.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) - expect(c.layers[2].nameString).to include("m tbd") - expect(c.layers[2].to_StandardOpaqueMaterial).to_not be_empty - m = c.layers[2].to_StandardOpaqueMaterial.get + expect(stpts[:heating]).to be_within(TOL).of(21.0) + expect(stpts[:cooling]).to be_within(TOL).of(24.0) - initial_R = surface[:filmRSI] + 2.4674 - derated_R = surface[:filmRSI] + 0.9931 - derated_R += m.thickness / m.thermalConductivity + expect(TBD.unconditioned?(attic)).to be false + expect(TBD.plenum?(attic)).to be true # works ... - ratio = -(initial_R - derated_R) * 100 / initial_R - expect(ratio).to be_within(1).of(surfaces[id][:ratio]) - else - if surface[:boundary] == "outdoors" - expect(surface[:conditioned]).to be false - end + 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) + io = json[:io ] + surfaces = json[:surfaces] + expect(TBD.error?).to be true + expect(TBD.logs.size).to eq(18) + expect(surfaces).to be_a(Hash) + expect(surfaces.size).to eq(18) + expect(io).to be_a(Hash) + expect(io).to have_key(:edges) + expect(io[:edges].size).to eq(35) + + # The incomplete (temporary) schedule triggers a non-FATAL TBD error. + TBD.logs.each do |log| + expect(log[:message]).to include("Empty '") + expect(log[:message]).to include("::scheduleCompactMinMax)") end - end - end - it "can process JSON surface KHI entries" do - translator = OpenStudio::OSVersion::VersionTranslator.new - expect(TBD.level ).to eq(DBG) - expect(TBD.clean!).to eq(DBG) + surfaces.each do |nom, surface| + expect(surface).to be_a(Hash) - # First, basic IO tests with invalid entries. - k = TBD::KHI.new - expect(k.point).to be_a(Hash) - expect(k.point.size).to eq(14) + expect(surface).to have_key(:conditioned) + expect(surface).to have_key(:deratable) + expect(surface).to have_key(:construction) + expect(surface).to have_key(:ground) + expect(surface).to have_key(:type) + next unless surface[:type] == :ceiling - # Invalid identifier key. - new_KHI = { name: "new_KHI", point: 1.0 } - expect(k.append(new_KHI)).to be false - expect(TBD.debug?).to be true - expect(TBD.logs.size).to eq(1) - expect(TBD.logs.first[:message]).to include("Missing 'id' key") - TBD.clean! + # Sloped attic roof surfaces no longer ignored by TBD. + id = surface[:construction].nameString + expect(nom).to include("-roof" ) if surface[:deratable] + expect(nom).to include("_Ceiling" ) unless surface[:deratable] + expect(id ).to include("BTAP-Ext-") if surface[:deratable] - # Invalid identifier. - new_KHI = { id: nil, point: 1.0 } - expect(k.append(new_KHI)).to be false - expect(TBD.error?).to be true - expect(TBD.logs.size).to eq(1) - expect(TBD.logs.first[:message]).to include("'KHI id' NilClass?") - TBD.clean! + expect(surface[:conditioned]).to be true + next unless surface[:deratable] + next unless surface.key?(:heatloss) - # Odd (yet valid) identifier. - new_KHI = { id: [], point: 1.0 } - expect(k.append(new_KHI)).to be true - expect(TBD.status).to be_zero - expect(k.point.keys).to include("[]") - expect(k.point.size).to eq(15) + # Leaves only insulated attic ceilings. + expect(id).to eq("BTAP-Ext-Roof-Metal:U-0.162") # original construction + s = model.getSurfaceByName(nom) + expect(s).to_not be_empty + s = s.get + 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") # TBD-derated + end - # Existing identifier. - new_KHI = { id: "code (Quebec)", point: 1.0 } - expect(k.append(new_KHI)).to be false - expect(TBD.error?).to be true - expect(TBD.logs.size).to eq(1) - expect(TBD.logs.first[:message]).to include("existing KHI entry") - TBD.clean! + # Once done, ensure temporary schedule is dissociated from the thermostat + # and deleted from the model. + tstat.resetHeatingSetpointTemperatureSchedule + expect(tstat.getHeatingSchedule).to be_empty - # Missing point conductance. - new_KHI = { id: "foo" } - expect(k.append(new_KHI)).to be false - expect(TBD.debug?).to be true - expect(TBD.logs.size).to eq(1) - expect(TBD.logs.first[:message]).to include("Missing 'point' key") + sched2 = model.getScheduleByName(identifier) + expect(sched2).to_not be_empty + sched2.get.remove + sched2 = model.getScheduleByName(identifier) + expect(sched2).to be_empty - # Valid JSON entries. - TBD.clean! - version = OpenStudio.openStudioVersion.split(".").join.to_i + heat = TBD.maxHeatScheduledSetpoint(zone) + stpts = TBD.setpoints(attic) - # The v1.11.5 (2016) seb.osm, shipped with OpenStudio, holds (what would now - # be considered as deprecated) a definition of plenum floors (i.e. ceiling - # tiles) generating several warnings with more recent OpenStudio versions. - file = File.join(__dir__, "files/osms/in/seb.osm") - path = OpenStudio::Path.new(file) - model = translator.loadModel(path) - expect(model).to_not be_empty - model = model.get + expect(heat).to be_a(Hash) + expect(heat).to have_key(:spt ) + expect(heat).to have_key(:dual) + expect(heat[:spt ]).to be_nil + expect(heat[:dual]).to be false - # "Shading Surface 4" is overlapping with a plenum exterior wall - delete. - sh4 = model.getShadingSurfaceByName("Shading Surface 4") - expect(sh4).to_not be_empty - sh4 = sh4.get - sh4.remove + expect(stpts[:heating]).to be_nil + expect(stpts[:cooling]).to be_nil + expect(TBD.plenum?(attic)).to be false # as before ... - plenum = model.getSpaceByName("Level 0 Ceiling Plenum") - expect(plenum).to_not be_empty - plenum = plenum.get - thzone = plenum.thermalZone - expect(thzone).to_not be_empty - thzone = thzone.get + # -- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- -- # + TBD.clean! - # Before the fix. - unless version < 350 - expect(plenum.isEnclosedVolume).to be true - expect(plenum.isVolumeDefaulted).to be true - expect(plenum.isVolumeAutocalculated).to be true - end + # Same, altered model from previous iteration (yet to uprate): + # - constructions: YES + # - setpoints : YES + # - HVAC : NO + file = File.join(__dir__, "files/osms/out/resto2a.osm") + path = OpenStudio::Path.new(file) + model = translator.loadModel(path) + expect(model).to_not be_empty + model = model.get + expect(model.getConstructions).to_not be_empty - if version > 350 && version < 370 - expect(plenum.volume.round(0)).to eq(234) - else - expect(plenum.volume.round(0)).to eq(0) - end + heated = TBD.heatingTemperatureSetpoints?(model) + cooled = TBD.coolingTemperatureSetpoints?(model) + expect(heated).to be true + expect(cooled).to be true - expect(thzone.isVolumeDefaulted).to be true - expect(thzone.isVolumeAutocalculated).to be true - expect(thzone.volume).to be_empty + # Get geometry data for testing (4x exterior roofs, same construction). + id = "BTAP-Ext-Roof-Metal:U-0.162" + construction = nil + roofs = [] - plenum.surfaces.each do |s| - next if s.outsideBoundaryCondition.downcase == "outdoors" + model.getSurfaces.each do |s| + next unless s.surfaceType == "RoofCeiling" + next unless s.outsideBoundaryCondition == "Outdoors" - # If a SEB plenum surface isn't facing outdoors, it's 1 of 4 "floor" - # surfaces (each facing a ceiling surface below). - adj = s.adjacentSurface - expect(adj).to_not be_empty - adj = adj.get - expect(adj.vertices.size).to eq(s.vertices.size) + roofs << s.nameString + c = s.construction + expect(c).to_not be_empty + c = c.get.to_LayeredConstruction + expect(c).to_not be_empty + c = c.get - # Same vertex sequence? Should be in reverse order. - adj.vertices.each_with_index do |vertex, i| - expect(TBD.same?(vertex, s.vertices.at(i))).to be true + construction = c if construction.nil? + expect(c).to eq(construction) end - expect(adj.surfaceType).to eq("RoofCeiling") - expect(s.surfaceType).to eq("RoofCeiling") - expect(s.setSurfaceType("Floor")).to be true - expect(s.setVertices(s.vertices.reverse)).to be true + expect(construction.getNetArea ).to be_within(TOL).of(569.51) + expect(roofs.size ).to eq( 4) + expect(construction.nameString ).to eq(id) + expect(construction.layers.size).to eq( 2) - # Vertices now in reverse order. - adj.vertices.reverse.each_with_index do |vertex, i| - expect(TBD.same?(vertex, s.vertices.at(i))).to be true - end - end + insulation = construction.layers[1].to_MasslessOpaqueMaterial + expect(insulation).to_not be_empty + insulation = insulation.get + original_r = insulation.thermalResistance + expect(original_r).to be_within(TOL).of(6.17) - # After the fix. - unless version < 350 - expect(plenum.isEnclosedVolume).to be true - expect(plenum.isVolumeDefaulted).to be true - expect(plenum.isVolumeAutocalculated).to be true - end + # Attic spacetype as plenum, an alternative to the inactive thermostat. + attic = model.getSpaceByName("attic") + expect(attic).to_not be_empty + attic = attic.get + sptype = attic.spaceType + expect(sptype).to_not be_empty + sptype = sptype.get + sptype.setName("Attic as Plenum") - expect(plenum.volume.round(0)).to eq(50) # right answer - expect(thzone.isVolumeDefaulted).to be true - expect(thzone.isVolumeAutocalculated).to be true - expect(thzone.volume).to be_empty + stpts = TBD.setpoints(attic) + expect(stpts[:heating]).to be_within(TOL).of(21.0) + expect(TBD.unconditioned?(attic)).to be false + expect(TBD.plenum?(attic)).to be true # works ... - file = File.join(__dir__, "files/osms/out/seb2.osm") - model.save(file, true) + argh = {} + argh[:option ] = "efficient (BETBG)" + argh[:uprate_walls] = true + argh[:uprate_roofs] = true + argh[:wall_option ] = "ALL wall constructions" + argh[:roof_option ] = "ALL roof constructions" + argh[:wall_ut ] = 0.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) + 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.status).to be_zero - argh = {} - argh[:option ] = "(non thermal bridging)" - argh[:io_path ] = File.join(__dir__, "../json/tbd_seb_n2.json") - argh[:schema_path] = File.join(__dir__, "../tbd.schema.json") + expect(argh).to have_key(:wall_uo) + expect(argh).to have_key(:roof_uo) + expect(argh[:roof_uo]).to be_within(TOL).of(0.120) # RSi 8.3 ( R47) + expect(argh[:wall_uo]).to be_within(TOL).of(0.012) # RSi 83.3 (R473) - 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.status).to be_zero - expect(TBD.logs).to be_empty - expect(surfaces).to be_a(Hash) - expect(surfaces.size).to eq(56) - expect(io).to be_a(Hash) - expect(io).to have_key(:edges) - expect(io[:edges].size).to eq(106) + # Validate ceiling surfaces (both insulated & uninsulated). + ua = 0.0 + a = 0 + area = 0 - # As the :building PSI set on file remains "(non thermal bridging)", one - # should not expect differences in results, i.e. derating shouldn't occur. - # However, the JSON file holds KHI entries for "Entryway Wall 2" : - # 3x "columns" @0.5 W/K + 4x supports @0.5W/K = 3.5 W/K - surfaces.values.each do |surface| - next unless surface.key?(:ratio) + 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) + expect(surface).to have_key(:type) + next if surface[:ground] + next unless surface[:type ] == :ceiling - expect(surface[:heatloss]).to be_within(TOL).of(3.5) - end + # Sloped plenum roof surfaces no longer ignored by TBD. + id = surface[:construction].nameString + expect(nom).to include("-roof" ) if surface[:deratable] + expect(id ).to include("BTAP-Ext-") if surface[:deratable] - # Retrieve :parapet edges along the "Open Area" plenum. - open = model.getSpaceByName("Open area 1") - expect(open).to_not be_empty - open = open.get + expect(surface[:conditioned]).to be true if surface[:deratable] + expect(nom).to include("_Ceiling") unless surface[:deratable] + expect(surface[:conditioned]).to be true unless surface[:deratable] - open_roofs = TBD.roofs(open) - expect(open_roofs.size).to eq(1) - open_roof = open_roofs.first - roof_id = open_roof.nameString - expect(roof_id).to eq("Level 0 Open area 1 Ceiling Plenum RoofCeiling") + next unless surface[:deratable] + next unless surface.key?(:heatloss) - # There are only 2 types of edges along the "Open Area" plenum roof: - # 1. (5x) convex :parapet edges, and - # 2. (5x) transition edges (shared with neighbouring flat roof surfaces). - roof_edges = io[:edges].select { |edg| edg[:surfaces].include?(roof_id) } - parapets = roof_edges.select { |edg| edg[:type] == :parapetconvex } - transitions = roof_edges.select { |edg| edg[:type] == :transition } - expect(parapets.size).to eq(5) - expect(transitions.size).to eq(5) - expect(roof_edges.size).to eq(parapets.size + transitions.size) + # Leaves only insulated plenum roof surfaces. + expect(id).to eq("BTAP-Ext-Roof-Metal:U-0.162") # original construction + s = model.getSurfaceByName(nom) + expect(s).to_not be_empty + s = s.get + 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") # TBD-derated - roof_edges.each { |edg| expect(edg[:surfaces].size).to eq(2) } - end + a += surface[:net] + ua += 1 / TBD.rsi(c, surface[:filmRSI]) * surface[:net] + end - it "can process JSON surface KHI & PSI entries" do - translator = OpenStudio::OSVersion::VersionTranslator.new - TBD.clean! + expect(ua / a).to be_within(TOL).of(argh[:roof_ut]) + + roofs.each do |roof| + expect(surfaces).to have_key(roof) + expect(surfaces[roof]).to have_key(:deratable) + expect(surfaces[roof]).to have_key(:edges) + expect(surfaces[roof][:deratable]).to be true + + surfaces[roof][:edges].values.each do |edge| + expect(edge).to have_key(:psi) + expect(edge).to have_key(:length) + expect(edge).to have_key(:ratio) + expect(edge).to have_key(:type) + next if edge[:type] == :transition + + expect(edge[:ratio]).to be_within(TOL).of(0.579) + expect(edge[:psi ]).to be_within(TOL).of(0.200 * edge[:ratio]) + end + + loss = 22.61 * 0.200 * 0.579 + expect(surfaces[roof]).to have_key(:heatloss) + expect(surfaces[roof]).to have_key(:net) + expect(surfaces[roof][:heatloss]).to be_within(TOL).of(loss) + area += surfaces[roof][:net] + end + + expect(area).to be_within(TOL).of(569.50) + end + # --- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- --- # + # Add skylight (+ skylight well) to corrected SEB model. + TBD.clean! file = File.join(__dir__, "files/osms/out/seb2.osm") path = OpenStudio::Path.new(file) model = translator.loadModel(path) expect(model).to_not be_empty model = model.get - # Consider the plenum as UNCONDITIONED. - plnum = model.getSpaceByName("Level 0 Ceiling Plenum") - expect(plnum).to_not be_empty - plnum = plnum.get - expect(TBD.unconditioned?(plnum)).to be false + entry = model.getSpaceByName("Entry way 1") + office = model.getSpaceByName("Small office 1") + open = model.getSpaceByName("Open area 1") + utility = model.getSpaceByName("Utility 1") + plenum = model.getSpaceByName("Level 0 Ceiling Plenum") + expect(entry).to_not be_empty + expect(office).to_not be_empty + expect(open).to_not be_empty + expect(utility).to_not be_empty + expect(plenum).to_not be_empty + entry = entry.get + office = office.get + open = open.get + utility = utility.get + plenum = plenum.get + expect(plenum.partofTotalFloorArea).to be false + expect(TBD.unconditioned?(plenum)).to be false - key = "space_conditioning_category" - val = "Unconditioned" - expect(plnum.additionalProperties.hasFeature(key)).to be false - expect(plnum.additionalProperties.setFeature(key, val)).to be true - expect(TBD.plenum?(plnum)).to be true - expect(TBD.unconditioned?(plnum)).to be true - expect(TBD.setpoints(plnum)[:heating]).to be_nil - expect(TBD.setpoints(plnum)[:cooling]).to be_nil + open_roofs = TBD.roofs(open) + expect(open_roofs.size).to eq(1) + open_roof = open_roofs.first + roof_id = open_roof.nameString + expect(roof_id).to eq("Level 0 Open area 1 Ceiling Plenum RoofCeiling") + + srr = 0.05 + gra = TBD.grossRoofArea(model.getSpaces) + tm2 = srr * gra + rm2 = TBD.addSkyLights(model.getSpaces, {area: tm2}) + puts TBD.logs unless TBD.logs.empty? + expect(TBD.status).to be_zero + expect(rm2.round(2)).to eq(gra.round(2)) + + entry_skies = TBD.facets(entry, "Outdoors", "Skylight") + office_skies = TBD.facets(office, "Outdoors", "Skylight") + utility_skies = TBD.facets(utility, "Outdoors", "Skylight") + open_skies = TBD.facets(open, "Outdoors", "Skylight") + + expect(entry_skies).to be_empty + expect(office_skies).to be_empty + expect(utility_skies).to be_empty + expect(open_skies.size).to eq(1) + open_sky = open_skies.first + sky_id = open_sky.nameString + expect(sky_id).to eq("0:0:0:Open area 1:0") + + skm2 = open_sky.grossArea + expect((skm2 / rm2).round(2)).to eq(srr) + + # Assign construction to new skylights. + construction = TBD.genConstruction(model, {type: :skylight, uo: 2.8}) + expect(open_sky.setConstruction(construction)).to be true + puts TBD.logs unless TBD.logs.empty? expect(TBD.status).to be_zero + file = File.join(__dir__, "files/osms/out/seb2_sky.osm") + model.save(file, true) + + open_well = open_sky.surface + expect(open_well).to_not be_empty + open_well = open_well.get + expect(open_well.surfaceType.downcase).to eq("roofceiling") + well_id = open_well.nameString + expect(well_id).to eq("0:0:0:Open area 1") + argh = {} - argh[:option ] = "(non thermal bridging)" # no :building PSI set on file - argh[:io_path ] = File.join(__dir__, "../json/tbd_seb_n3.json") + argh[:option ] = "regular (BETBG)" argh[:schema_path] = File.join(__dir__, "../tbd.schema.json") json = TBD.process(model, argh) @@ -3527,358 +3145,168 @@ expect(TBD.status).to be_zero expect(TBD.logs).to be_empty expect(surfaces).to be_a(Hash) - expect(surfaces.size).to eq(56) + expect(surfaces.size).to eq(65) # ! 56 before skylight/well/leader lines expect(io).to be_a(Hash) expect(io).to have_key(:edges) - expect(io[:edges].size).to eq(80) # 106 if plnum INDIRECTLYCONDITIONED - - expect(io).to have_key(:building) # despite no being on file - good - expect(io[:building]).to have_key(:psi) - expect(io[:building][:psi]).to eq("(non thermal bridging)") - - # As the :building PSI set on file remains "(non thermal bridging)", one - # should not expect differences in results, i.e. derating shouldn't occur - # for most surfaces. However, the JSON file holds KHI entries for - # "Entryway Wall 5": - # 3x "columns" @0.5 W/K + 4x supports @0.5W/K = 3.5 W/K (as in case above), - # and a "good" PSI set (:parapet, of 0.5 W/K per m). - nom1 = "Entryway Wall 5" - nom2 = "Entry way DroppedCeiling" + expect(io[:edges].size).to eq(115) # ! 106 before skylight/well/leader lines - surfaces.each do |id, surface| - next unless surface.key?(:ratio) + # Extra 9 edges: + # - 4x new "skylightjamb" edges + # - 4x new "transition" edges around well + # - 1x "transition" edge along leader line, required for well cutout. + sky_jambs = io[:edges].select { |ed| ed[:surfaces].include?(sky_id) } + expect(sky_jambs.size).to eq(4) - expect(id).to eq(nom1).or eq(nom2) - expect(surface[:heatloss]).to be_within(TOL).of(5.17) if id == nom1 - expect(surface[:heatloss]).to be_within(TOL).of(0.13) if id == nom2 - expect(surface).to have_key(:edges) - expect(surface[:edges].size).to eq(10) if id == nom1 - expect(surface[:edges].size).to eq( 6) if id == nom2 + sky_jambs.each do |edg| + expect(edg[:surfaces].size).to eq(2) + expect(edg[:surfaces]).to include(well_id) + expect(edg[:type]).to eq(:skylightjamb) end - expect(io).to have_key(:edges) - expect(io[:edges].size).to eq(80) + roof_edges = io[:edges].select { |ed| ed[:surfaces].include?(roof_id) } + parapets = roof_edges.select { |ed| ed[:type] == :parapetconvex } + transitions = roof_edges.select { |ed| ed[:type] == :transition } + expect(parapets.size).to eq(5) + expect(transitions.size).to eq(10) + expect(roof_edges.size).to eq(parapets.size + transitions.size) - # The JSON input file (tbd_seb_n3.json) holds 2x PSI sets: - # - "good" for "Entryway Wall 5" - # - "compliant" (ignored) - # - # The PSI set "good" only holds non-zero PSI values for: - # - :rimjoist (there are none for "Entryway Wall 5") - # - :parapet (a single edge shared with "Entry way DroppedCeiling") - # - # Only those 2x surfaces will be derated. The following counters track the - # total number of edges delineating either derated surfaces that contribute - # in derating their insulation materials i.e. found in the "good" PSI set. - nb_rimjoist_edges = 0 - nb_parapet_edges = 0 - nb_fen_edges = 0 - nb_head_edges = 0 - nb_sill_edges = 0 - nb_jamb_edges = 0 - nb_corners = 0 - nb_concave_edges = 0 - nb_convex_edges = 0 - nb_balcony_edges = 0 - nb_party_edges = 0 - nb_grade_edges = 0 - nb_transition_edges = 0 + parapets.each { |edg| expect(edg[:surfaces].size).to eq(2) } - io[:edges].each do |edge| - expect(edge).to have_key(:psi) - expect(edge).to have_key(:type) - expect(edge).to have_key(:length) - expect(edge).to have_key(:surfaces) - t = edge[:type] - s = {} - valid = edge[:surfaces].include?(nom1) || edge[:surfaces].include?(nom2) - next unless valid + t1x = transitions.select { |edg| edg[:surfaces].size == 1 } + t2x = transitions.select { |edg| edg[:surfaces].size == 2 } + t4x = transitions.select { |edg| edg[:surfaces].size == 4 } + expect(t1x.size).to eq(1) # leader line + expect(t2x.size).to eq(5) # see "can process JSON surface KHI entries" + expect(t4x.size).to eq(4) # around skylight well - io[:psis].each { |set| s = set if set[:id] == edge[:psi] } + expect(transitions.size).to eq(t1x.size + t2x.size + t4x.size) - next if s.empty? + # Skylight well cutout leader line backtracks onto itself. + t1x = t1x.first + expect(t1x[:surfaces]).to include(roof_id) - expect(s).to be_a(Hash) - nb_rimjoist_edges += 1 if t == :rimjoist - nb_rimjoist_edges += 1 if t == :rimjoistconcave - nb_rimjoist_edges += 1 if t == :rimjoistconvex - nb_parapet_edges += 1 if t == :parapet - nb_parapet_edges += 1 if t == :parapetconcave - nb_parapet_edges += 1 if t == :parapetconvex - nb_fen_edges += 1 if t == :fenestration - nb_head_edges += 1 if t == :head - nb_sill_edges += 1 if t == :sill - nb_jamb_edges += 1 if t == :jamb - nb_corners += 1 if t == :corner - nb_concave_edges += 1 if t == :cornerconcave - nb_convex_edges += 1 if t == :cornerconvex - nb_balcony_edges += 1 if t == :balcony - nb_party_edges += 1 if t == :party - nb_grade_edges += 1 if t == :grade - nb_grade_edges += 1 if t == :gradeconcave - nb_grade_edges += 1 if t == :gradeconvex - nb_transition_edges += 1 if t == :transition + t4x.each do |edg| + expect(edg[:surfaces].size).to eq(4) + expect(edg[:surfaces]).to include(roof_id) # roof with cutout + expect(edg[:surfaces]).to include(well_id) # new base surface for skylight - expect(t).to eq(:parapetconvex).or eq(:transition) - next unless t == :parapetconvex + edg[:surfaces].each do |s| + next if s == roof_id + next if s == well_id - expect(edge[:length]).to be_within(TOL).of(3.6) + expect(s).to include("0:0:0:0:") + # e.g.: + # ... Level 0 Open area 1 Ceiling Plenum RoofCeiling (i.e. roof_id) + # ... 0:0:0:Open area 1 (i.e. well_id) + # ... 0:0:0:0:3:Level 0 Ceiling Plenum (i.e. well wall, plenum side) + # ... 0:0:0:0:3:Open area 1 (i.e. adjacent well wall, open area side) + end end - expect(nb_rimjoist_edges ).to be_zero - expect(nb_parapet_edges ).to eq(1) # parapet linked to "good" PSI set - expect(nb_fen_edges ).to be_zero - expect(nb_head_edges ).to be_zero - expect(nb_sill_edges ).to be_zero - expect(nb_jamb_edges ).to be_zero - expect(nb_corners ).to be_zero - expect(nb_concave_edges ).to be_zero - expect(nb_convex_edges ).to be_zero - expect(nb_balcony_edges ).to be_zero - expect(nb_party_edges ).to be_zero - expect(nb_grade_edges ).to be_zero - expect(nb_transition_edges).to eq(2) # all PSI sets inherit :transitions - - # Reset counters to track the total number of edges delineating either - # derated surfaces that DO NOT contribute in derating their insulation - # materials i.e. not found in the "good" PSI set. - nb_rimjoist_edges = 0 - nb_parapet_edges = 0 - nb_fen_edges = 0 - nb_head_edges = 0 - nb_sill_edges = 0 - nb_jamb_edges = 0 - nb_corners = 0 - nb_concave_edges = 0 - nb_convex_edges = 0 - nb_balcony_edges = 0 - nb_party_edges = 0 - nb_grade_edges = 0 - nb_transition_edges = 0 + puts TBD.logs unless TBD.logs.empty? + expect(TBD.status).to be_zero - io[:edges].each do |edge| - s = {} - valid = edge[:surfaces].include?(nom1) || edge[:surfaces].include?(nom2) - next unless valid + file = File.join(__dir__, "files/osms/out/seb2_sky2.osm") + model.save(file, true) + end - io[:psis].each { |set| s = set if set[:id] == edge[:psi] } + it "can factor in negative PSI-factors (JSON input)" do + translator = OpenStudio::OSVersion::VersionTranslator.new + TBD.clean! - next unless s.empty? - expect(edge[:psi]).to eq(argh[:option]) + 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 - t = edge[:type] - nb_rimjoist_edges += 1 if t == :rimjoist - nb_rimjoist_edges += 1 if t == :rimjoistconcave - nb_rimjoist_edges += 1 if t == :rimjoistconvex - nb_parapet_edges += 1 if t == :parapet - nb_parapet_edges += 1 if t == :parapetconcave - nb_parapet_edges += 1 if t == :parapetconvex - nb_fen_edges += 1 if t == :fenestration - nb_head_edges += 1 if t == :head - nb_sill_edges += 1 if t == :sill - nb_jamb_edges += 1 if t == :jamb - nb_corners += 1 if t == :corner - nb_concave_edges += 1 if t == :cornerconcave - nb_convex_edges += 1 if t == :cornerconvex - nb_balcony_edges += 1 if t == :balcony - nb_party_edges += 1 if t == :party - nb_grade_edges += 1 if t == :grade - nb_grade_edges += 1 if t == :gradeconcave - nb_grade_edges += 1 if t == :gradeconvex - nb_transition_edges += 1 if t == :transition - end + argh = {} + argh[:option ] = "compliant" # superseded by :building PSI set on file + argh[:io_path ] = File.join(__dir__, "../json/tbd_warehouse4.json") + argh[:schema_path] = File.join(__dir__, "../tbd.schema.json") - expect(nb_rimjoist_edges ).to be_zero - expect(nb_parapet_edges ).to eq(2) # not linked to "good" PSI set - expect(nb_fen_edges ).to be_zero - expect(nb_head_edges ).to eq(1) - expect(nb_sill_edges ).to eq(1) - expect(nb_jamb_edges ).to eq(2) - expect(nb_corners ).to be_zero - expect(nb_concave_edges ).to be_zero - expect(nb_convex_edges ).to eq(2) # edges between walls 5 & 4 - expect(nb_balcony_edges ).to be_zero - expect(nb_party_edges ).to be_zero - expect(nb_grade_edges ).to eq(1) - expect(nb_transition_edges).to eq(3) # shared roof edges + 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.status).to be_zero + expect(TBD.logs).to be_empty + expect(surfaces).to be_a Hash + expect(surfaces.size).to eq(23) + expect(io).to be_a(Hash) + expect(io).to have_key(:edges) + expect(io[:edges].size).to eq(300) - # Reset counters again to track the total number of edges delineating either - # derated surfaces that DO NOT contribute in derating their insulation - # materials i.e., automatically set as :transitions in "good" PSI set. - nb_rimjoist_edges = 0 - nb_parapet_edges = 0 - nb_fen_edges = 0 - nb_head_edges = 0 - nb_sill_edges = 0 - nb_jamb_edges = 0 - nb_corners = 0 - nb_concave_edges = 0 - nb_convex_edges = 0 - nb_balcony_edges = 0 - nb_party_edges = 0 - nb_grade_edges = 0 - nb_transition_edges = 0 + ids = { a: "Office Front Wall", + b: "Office Left Wall", + c: "Fine Storage Roof", + d: "Fine Storage Office Front Wall", + e: "Fine Storage Office Left Wall", + f: "Fine Storage Front Wall", + g: "Fine Storage Left Wall", + h: "Fine Storage Right Wall", + i: "Bulk Storage Roof", + j: "Bulk Storage Rear Wall", + k: "Bulk Storage Left Wall", + l: "Bulk Storage Right Wall" }.freeze - io[:edges].each do |edge| - t = edge[:type] - s = {} - valid = edge[:surfaces].include?(nom1) || edge[:surfaces].include?(nom2) - next unless valid + surfaces.each do |id, surface| + expect(ids).to have_value(id) if surface.key?(:edges) + expect(ids).to_not have_value(id) unless surface.key?(:edges) + end - io[:psis].each { |set| s = set if set[:id] == edge[:psi] } + surfaces.each do |id, surface| + next unless surface[:boundary] == "outdoors" + next unless surface.key?(:ratio) - next if s.empty? + expect(ids).to have_value(id) + expect(surface).to have_key(:heatloss) - expect(s).to be_a(Hash) - next if t.to_s.include?("parapet") + # Ratios are typically negative e.g., a steel corner column decreasing + # linked surface RSi values. In some cases, a corner PSI can be positive + # (and thus increasing linked surface RSi values). This happens when + # estimating PSI-factors for convex corners while relying on an interior + # dimensioning convention e.g., BETBG Detail 7.6.2, ISO 14683. + expect(surface[:ratio]).to be_within(TOL).of(0.18) if id == ids[:a] + expect(surface[:ratio]).to be_within(TOL).of(0.55) if id == ids[:b] + expect(surface[:ratio]).to be_within(TOL).of(0.15) if id == ids[:d] + expect(surface[:ratio]).to be_within(TOL).of(0.43) if id == ids[:e] + expect(surface[:ratio]).to be_within(TOL).of(0.20) if id == ids[:f] + expect(surface[:ratio]).to be_within(TOL).of(0.13) if id == ids[:h] + expect(surface[:ratio]).to be_within(TOL).of(0.12) if id == ids[:j] + expect(surface[:ratio]).to be_within(TOL).of(0.04) if id == ids[:k] + expect(surface[:ratio]).to be_within(TOL).of(0.04) if id == ids[:l] - nb_rimjoist_edges += 1 if t == :rimjoist - nb_rimjoist_edges += 1 if t == :rimjoistconcave - nb_rimjoist_edges += 1 if t == :rimjoistconvex - nb_parapet_edges += 1 if t == :parapet - nb_parapet_edges += 1 if t == :parapetconcave - nb_parapet_edges += 1 if t == :parapetconvex - nb_fen_edges += 1 if t == :fenestration - nb_head_edges += 1 if t == :head - nb_sill_edges += 1 if t == :sill - nb_jamb_edges += 1 if t == :jamb - nb_corners += 1 if t == :corner - nb_concave_edges += 1 if t == :cornerconcave - nb_convex_edges += 1 if t == :cornerconvex - nb_balcony_edges += 1 if t == :balcony - nb_party_edges += 1 if t == :party - nb_grade_edges += 1 if t == :grade - nb_grade_edges += 1 if t == :gradeconcave - nb_grade_edges += 1 if t == :gradeconvex - nb_transition_edges += 1 if t == :transition + # In such cases, negative heatloss means heat gained. + expect(surface[:heatloss]).to be_within(TOL).of(-0.10) if id == ids[:a] + expect(surface[:heatloss]).to be_within(TOL).of(-0.10) if id == ids[:b] + expect(surface[:heatloss]).to be_within(TOL).of(-0.10) if id == ids[:d] + expect(surface[:heatloss]).to be_within(TOL).of(-0.10) if id == ids[:e] + expect(surface[:heatloss]).to be_within(TOL).of(-0.20) if id == ids[:f] + expect(surface[:heatloss]).to be_within(TOL).of(-0.20) if id == ids[:h] + expect(surface[:heatloss]).to be_within(TOL).of(-0.40) if id == ids[:j] + expect(surface[:heatloss]).to be_within(TOL).of(-0.20) if id == ids[:k] + expect(surface[:heatloss]).to be_within(TOL).of(-0.20) if id == ids[:l] end - - expect(nb_rimjoist_edges ).to be_zero - expect(nb_parapet_edges ).to be_zero - expect(nb_fen_edges ).to be_zero - expect(nb_head_edges ).to be_zero - expect(nb_jamb_edges ).to be_zero - expect(nb_sill_edges ).to be_zero - expect(nb_corners ).to be_zero - expect(nb_concave_edges ).to be_zero - expect(nb_convex_edges ).to be_zero - expect(nb_balcony_edges ).to be_zero - expect(nb_party_edges ).to be_zero - expect(nb_grade_edges ).to be_zero - expect(nb_transition_edges).to eq(2) # edges between walls 5 & 6 end - it "can process JSON surface KHI & PSI entries + building & edge" do + it "can check for balcony sills (ASHRAE 90.1 2022/25)" do translator = OpenStudio::OSVersion::VersionTranslator.new TBD.clean! - # First, basic IO tests with invalid entries. - ps = TBD::PSI.new - expect(ps.set).to be_a(Hash) - expect(ps.has).to be_a(Hash) - expect(ps.val).to be_a(Hash) - expect(ps.set.size).to eq(16) - expect(ps.has.size).to eq(16) - expect(ps.val.size).to eq(16) - - expect(ps.gen(nil)).to be false - expect(TBD.status).to be_zero - - # Invalid identifier key. - new_PSI = { name: "new_PSI", balcony: 1.0 } - expect(ps.append(new_PSI)).to be false - expect(TBD.debug?).to be true - expect(TBD.logs.size).to eq(1) - expect(TBD.logs.first[:message]).to include("Missing 'id' key") - TBD.clean! - - # Invalid identifier. - new_PSI = { id: nil, balcony: 1.0 } - expect(ps.append(new_PSI)).to be false - expect(TBD.error?).to be true - expect(TBD.logs.size).to eq(1) - expect(TBD.logs.first[:message]).to include("'set ID' NilClass?") - TBD.clean! - - # Odd (yet valid) identifier. - new_PSI = { id: [], balcony: 1.0 } - expect(ps.append(new_PSI)).to be true - expect(TBD.status).to be_zero - expect(ps.set.keys).to include("[]") - expect(ps.has.keys).to include("[]") - expect(ps.val.keys).to include("[]") - expect(ps.set.size).to eq(17) - expect(ps.has.size).to eq(17) - expect(ps.val.size).to eq(17) - - # Existing identifier. - new_PSI = { id: "code (Quebec)", balcony: 1.0 } - expect(ps.append(new_PSI)).to be false - expect(TBD.error?).to be true - expect(TBD.logs.size).to eq(1) - expect(TBD.logs.first[:message]).to include("existing PSI set") - TBD.clean! - - # Side test on balcony/sill. - expect(ps.safe("code (Quebec)", :balconysillconcave)).to eq(:balconysill) - - # Defined vs missing conductances. - new_PSI = { id: "foo" } - expect(ps.append(new_PSI)).to be true - - s = ps.shorthands("foo") - expect(TBD.status).to be_zero - expect(s).to be_a(Hash) - expect(s).to have_key(:has) - expect(s).to have_key(:val) - - [:joint, :transition].each do |type| - expect(s[:has]).to have_key(type) - expect(s[:val]).to have_key(type) - expect(s[:has][type]).to be true - expect(s[:val][type]).to be_within(TOL).of(0) - end - - [:balcony, :rimjoist, :fenestration, :parapet].each do |type| - expect(s[:has]).to have_key(type) - expect(s[:val]).to have_key(type) - expect(s[:has][type]).to be false - expect(s[:val][type]).to be_within(TOL).of(0) - end - - # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # - # Valid JSON entries. - TBD.clean! - - name = "Entryway Wall 5" - file = File.join(__dir__, "files/osms/out/seb2.osm") + # "Lo Scrigno" (or Jewel Box), by Renzo Piano (Lingotto Factory, Turin); a + # cantilevered, single space art gallery (space #1) above a supply plenum + # with slanted undersides (space #2) resting on four main pillars. + file = File.join(__dir__, "files/osms/in/loscrigno.osm") path = OpenStudio::Path.new(file) model = translator.loadModel(path) expect(model).to_not be_empty model = model.get - # Consider the plenum as UNCONDITIONED. - plnum = model.getSpaceByName("Level 0 Ceiling Plenum") - expect(plnum).to_not be_empty - plnum = plnum.get - expect(TBD.unconditioned?(plnum)).to be false - - key = "space_conditioning_category" - val = "Unconditioned" - expect(plnum.additionalProperties.hasFeature(key)).to be false - expect(plnum.additionalProperties.setFeature(key, val)).to be true - expect(TBD.plenum?(plnum)).to be true - expect(TBD.unconditioned?(plnum)).to be true - expect(TBD.setpoints(plnum)[:heating]).to be_nil - expect(TBD.setpoints(plnum)[:cooling]).to be_nil - expect(TBD.status).to be_zero - - argh = {} - argh[:option ] = "(non thermal bridging)" - argh[:io_path ] = File.join(__dir__, "../json/tbd_seb_n4.json") - argh[:schema_path] = File.join(__dir__, "../tbd.schema.json") - - json = TBD.process(model, argh) + argh = { option: "90.1.22|steel.m|default" } + json = TBD.process(model, argh) expect(json).to be_a(Hash) expect(json).to have_key(:io) expect(json).to have_key(:surfaces) @@ -3886,96 +3314,226 @@ surfaces = json[:surfaces] expect(TBD.status).to be_zero expect(TBD.logs).to be_empty - expect(surfaces).to be_a(Hash) - expect(surfaces.size).to eq(56) + expect(surfaces).to be_a Hash + expect(surfaces.size).to eq(31) expect(io).to be_a(Hash) expect(io).to have_key(:edges) - expect(io[:edges].size).to eq(80) - - # As the :building PSI set on file == "(non thermal bridging)", derating - # shouldn't occur at large. However, the JSON file holds a custom edge - # entry for "Entryway Wall 5" : "bad" fenestration perimeters, which - # only derates the host wall itself - surfaces.each do |id, surface| - 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 - end - end + expect(io[:edges].size).to eq(77) - it "can process JSON surface KHI & PSI + building & edge (2)" do - translator = OpenStudio::OSVersion::VersionTranslator.new - TBD.clean! + n_edges_at_grade = 0 + n_edges_as_balconies = 0 + n_edges_as_balconysills = 0 + n_edges_as_balconydoorsills = 0 + n_edges_as_concave_parapets = 0 + n_edges_as_convex_parapets = 0 + n_edges_as_concave_roofs = 0 + n_edges_as_convex_roofs = 0 + n_edges_as_rimjoists = 0 + n_edges_as_concave_rimjoists = 0 + n_edges_as_convex_rimjoists = 0 + n_edges_as_fenestrations = 0 + n_edges_as_heads = 0 + n_edges_as_sills = 0 + n_edges_as_jambs = 0 + n_edges_as_doorheads = 0 + n_edges_as_doorsills = 0 + n_edges_as_doorjambs = 0 + n_edges_as_skylightjambs = 0 + n_edges_as_concave_jambs = 0 + n_edges_as_convex_jambs = 0 + n_edges_as_corners = 0 + n_edges_as_concave_corners = 0 + n_edges_as_convex_corners = 0 + n_edges_as_transitions = 0 - file = File.join(__dir__, "files/osms/out/seb2.osm") - path = OpenStudio::Path.new(file) - model = translator.loadModel(path) - expect(model).to_not be_empty - model = model.get + io[:edges].each do |edge| + expect(edge).to have_key(:type) - argh = {} - argh[:option ] = "(non thermal bridging)" - argh[:io_path ] = File.join(__dir__, "../json/tbd_seb_n5.json") - argh[:schema_path] = File.join(__dir__, "../tbd.schema.json") + n_edges_at_grade += 1 if edge[:type] == :grade + n_edges_at_grade += 1 if edge[:type] == :gradeconcave + n_edges_at_grade += 1 if edge[:type] == :gradeconvex + n_edges_as_balconies += 1 if edge[:type] == :balcony + n_edges_as_balconies += 1 if edge[:type] == :balconyconcave + n_edges_as_balconies += 1 if edge[:type] == :balconyconvex + n_edges_as_balconysills += 1 if edge[:type] == :balconysill + n_edges_as_balconysills += 1 if edge[:type] == :balconysillconcave + n_edges_as_balconysills += 1 if edge[:type] == :balconysillconvex + n_edges_as_balconydoorsills += 1 if edge[:type] == :balconydoorsill + n_edges_as_balconydoorsills += 1 if edge[:type] == :balconydoorsillconcave + n_edges_as_balconydoorsills += 1 if edge[:type] == :balconydoorsillconvex + n_edges_as_concave_parapets += 1 if edge[:type] == :parapetconcave + n_edges_as_convex_parapets += 1 if edge[:type] == :parapetconvex + n_edges_as_concave_roofs += 1 if edge[:type] == :roofconcave + n_edges_as_convex_roofs += 1 if edge[:type] == :roofconvex + n_edges_as_rimjoists += 1 if edge[:type] == :rimjoist + n_edges_as_concave_rimjoists += 1 if edge[:type] == :rimjoistconcave + n_edges_as_convex_rimjoists += 1 if edge[:type] == :rimjoistconvex + n_edges_as_fenestrations += 1 if edge[:type] == :fenestration + n_edges_as_heads += 1 if edge[:type] == :head + n_edges_as_heads += 1 if edge[:type] == :headconcave + n_edges_as_heads += 1 if edge[:type] == :headconvex + n_edges_as_sills += 1 if edge[:type] == :sill + n_edges_as_sills += 1 if edge[:type] == :sillconcave + n_edges_as_sills += 1 if edge[:type] == :sillconvex + n_edges_as_jambs += 1 if edge[:type] == :jamb + n_edges_as_concave_jambs += 1 if edge[:type] == :jambconcave + n_edges_as_convex_jambs += 1 if edge[:type] == :jambconvex + n_edges_as_doorheads += 1 if edge[:type] == :doorhead + n_edges_as_doorsills += 1 if edge[:type] == :doorsill + n_edges_as_doorjambs += 1 if edge[:type] == :doorjamb + n_edges_as_skylightjambs += 1 if edge[:type] == :skylightjamb + n_edges_as_skylightjambs += 1 if edge[:type] == :skylightjambconvex + n_edges_as_corners += 1 if edge[:type] == :corner + n_edges_as_concave_corners += 1 if edge[:type] == :cornerconcave + n_edges_as_convex_corners += 1 if edge[:type] == :cornerconvex + n_edges_as_transitions += 1 if edge[:type] == :transition + end - 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.status).to be_zero - expect(TBD.logs).to be_empty - expect(surfaces).to be_a(Hash) - expect(surfaces.size).to eq(56) - expect(io).to be_a(Hash) - expect(io).to have_key(:edges) - expect(io[:edges].size).to eq(106) + # Lo Scrigno holds 8x wall/roof edges: + # - 4x along gallery roof/skylight (all convex) + # - 4x along the elevator roof (3x convex + 1x concave) + # + # The gallery wall/roof edges are not modelled here "as built", but rather + # closer to details of another Renzo Piano extension: the Modern Wing of the + # Art Institute of Chicago. Both galleries are similar in that daylighting + # is zenithal, covering all (or nearly all) of the roof surface. In the + # case of Chicago, the roof is ~entirely glazed (as reflected in the model). + # + # www.archdaily.com/24652/the-modern-wing-renzo-piano/ + # 5010473228ba0d42220015f8-the-modern-wing-renzo-piano-image?next_project=no + # + # However, a small 1" strip is maintained along the South roof/wall edge of + # the gallery to ensure skylight area < roof area. + # + # No judgement here on the suitability of the design for either Chicago or + # 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/2025, only "vertical fenestration" edge PSI-factors + # are explicitely stated/published. Many other edges, such as: + # + # - skylight perimeters + # - non-fenestrated door perimeters + # - corners + # + # ... 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: + # + # unmethours.com/question/97085/901-2022-requirements-for-linear-thermal-bridges + # + # 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) + expect(n_edges_as_balconydoorsills ).to eq( 0) + expect(n_edges_as_concave_parapets ).to eq( 1) + expect(n_edges_as_convex_parapets ).to eq(11) + expect(n_edges_as_concave_roofs ).to eq( 0) + expect(n_edges_as_convex_roofs ).to eq( 0) + expect(n_edges_as_rimjoists ).to eq( 5) + expect(n_edges_as_concave_rimjoists).to eq( 5) + expect(n_edges_as_convex_rimjoists ).to eq(18) + expect(n_edges_as_fenestrations ).to eq( 0) + expect(n_edges_as_heads ).to eq( 2) # GlassDoor == fenestration + expect(n_edges_as_sills ).to eq( 0) # (2x balconysills) + expect(n_edges_as_jambs ).to eq( 4) + expect(n_edges_as_concave_jambs ).to eq( 0) + expect(n_edges_as_convex_jambs ).to eq( 0) + expect(n_edges_as_doorheads ).to eq( 0) + expect(n_edges_as_doorjambs ).to eq( 0) + expect(n_edges_as_doorsills ).to eq( 0) + expect(n_edges_as_skylightjambs ).to eq( 1) # along 1" rooftop strip + expect(n_edges_as_corners ).to eq( 0) + expect(n_edges_as_concave_corners ).to eq( 4) + expect(n_edges_as_convex_corners ).to eq(12) + expect(n_edges_as_transitions ).to eq(10) - # As above, yet the KHI points are now set @0.5 W/K per m (instead of 0) - surfaces.each do |id, surface| - next unless surface[:boundary] == "outdoors" + # For the purposes of the RSpec, vertical access (elevator and stairs, + # normally fully glazed) are modelled as (opaque) extensions of either + # space. Deratable (exterior) surfaces are grouped, prefixed as follows: + # + # - "g_" : art gallery + # - "p_" : underfloor plenum (supplying gallery) + # - "s_" : stairwell (leading to/through plenum & gallery) + # - "e_" : (side) elevator leading to gallery + # + # East vs West walls have equal heat loss (W/K) from major thermal bridging + # as they are symmetrical. North vs South walls differ slightly due to: + # - adjacency with elevator walls + # - different balcony lengths + expect(surfaces["g_E_wall" ][:heatloss]).to be_within(TOL).of( 4.30) + expect(surfaces["g_W_wall" ][:heatloss]).to be_within(TOL).of( 4.30) + expect(surfaces["g_N_wall" ][:heatloss]).to be_within(TOL).of(15.95) + expect(surfaces["g_S1_wall" ][:heatloss]).to be_within(TOL).of( 1.87) + expect(surfaces["g_S2_wall" ][:heatloss]).to be_within(TOL).of( 1.04) + expect(surfaces["g_S3_wall" ][:heatloss]).to be_within(TOL).of( 8.19) - expect(surface).to_not have_key(:ratio) unless id == "Entryway Wall 5" - next unless id == "Entryway Wall 5" + expect(surfaces["e_top" ][:heatloss]).to be_within(TOL).of( 1.43) + expect(surfaces["e_E_wall" ][:heatloss]).to be_within(TOL).of( 0.32) + expect(surfaces["e_W_wall" ][:heatloss]).to be_within(TOL).of( 0.32) + expect(surfaces["e_N_wall" ][:heatloss]).to be_within(TOL).of( 0.95) + expect(surfaces["e_S_wall" ][:heatloss]).to be_within(TOL).of( 0.85) + expect(surfaces["e_floor" ][:heatloss]).to be_within(TOL).of( 2.46) - expect(surface[:heatloss]).to be_within(TOL).of(12.39) - end + expect(surfaces["s_E_wall" ][:heatloss]).to be_within(TOL).of( 1.17) + expect(surfaces["s_W_wall" ][:heatloss]).to be_within(TOL).of( 1.17) + expect(surfaces["s_N_wall" ][:heatloss]).to be_within(TOL).of( 1.54) + expect(surfaces["s_S_wall" ][:heatloss]).to be_within(TOL).of( 1.54) + expect(surfaces["s_floor" ][:heatloss]).to be_within(TOL).of( 2.70) + + expect(surfaces["p_W1_floor"][:heatloss]).to be_within(TOL).of( 4.23) + expect(surfaces["p_W2_floor"][:heatloss]).to be_within(TOL).of( 1.82) + expect(surfaces["p_W3_floor"][:heatloss]).to be_within(TOL).of( 1.82) + expect(surfaces["p_W4_floor"][:heatloss]).to be_within(TOL).of( 0.58) + expect(surfaces["p_E_floor" ][:heatloss]).to be_within(TOL).of( 5.73) + expect(surfaces["p_N_wall" ][:heatloss]).to be_within(TOL).of(11.44) + expect(surfaces["p_S2_wall" ][:heatloss]).to be_within(TOL).of( 8.16) + expect(surfaces["p_S1_wall" ][:heatloss]).to be_within(TOL).of( 2.04) + expect(surfaces["p_floor" ][:heatloss]).to be_within(TOL).of( 3.07) + + expect(argh).to have_key(:io) + out = JSON.pretty_generate(argh[:io]) + outP = File.join(__dir__, "../json/tbd_loscrigno1.out.json") + File.open(outP, "w") { |outP| outP.puts out } end - it "can process JSON surface KHI & PSI + building & edge (3)" do + it "can switch between parapet/roof edge types" do translator = OpenStudio::OSVersion::VersionTranslator.new TBD.clean! - file = File.join(__dir__, "files/osms/out/seb2.osm") + file = File.join(__dir__, "files/osms/in/loscrigno.osm") path = OpenStudio::Path.new(file) model = translator.loadModel(path) expect(model).to_not be_empty model = model.get - # Consider the plenum as UNCONDITIONED. - plnum = model.getSpaceByName("Level 0 Ceiling Plenum") + # Ensure the plenum is 'unoccupied', i.e. not part of the total floor area. + plnum = model.getSpaceByName("scrigno_plenum") expect(plnum).to_not be_empty plnum = plnum.get - expect(TBD.unconditioned?(plnum)).to be false + expect(plnum.setPartofTotalFloorArea(false)).to be true - key = "space_conditioning_category" - val = "Unconditioned" - expect(plnum.additionalProperties.hasFeature(key)).to be false - expect(plnum.additionalProperties.setFeature(key, val)).to be true - expect(TBD.plenum?(plnum)).to be true - expect(TBD.unconditioned?(plnum)).to be true - expect(TBD.setpoints(plnum)[:heating]).to be_nil - expect(TBD.setpoints(plnum)[:cooling]).to be_nil - expect(TBD.status).to be_zero + # As a side test, switch glass doors to (opaque) doors. + model.getSubSurfaces.each do |sub| + next unless sub.subSurfaceType.downcase == "glassdoor" - argh = {} - argh[:option ] = "(non thermal bridging)" - argh[:io_path ] = File.join(__dir__, "../json/tbd_seb_n6.json") - argh[:schama_path] = File.join(__dir__, "../tbd.schema.json") + expect(sub.setSubSurfaceType("Door")).to be true + end - json = TBD.process(model, argh) + # Switching wall/roof edges from/to: + # - "parapet" PSI-factor 0.26 W/K•m + # - "roof" PSI-factor 0.02 W/K•m !! + # + # ... 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) expect(json).to be_a(Hash) expect(json).to have_key(:io) expect(json).to have_key(:surfaces) @@ -3983,55 +3541,116 @@ surfaces = json[:surfaces] expect(TBD.status).to be_zero expect(TBD.logs).to be_empty - expect(surfaces).to be_a(Hash) - expect(surfaces.size).to eq(56) + expect(surfaces).to be_a Hash + expect(surfaces.size).to eq(31) expect(io).to be_a(Hash) expect(io).to have_key(:edges) - expect(io[:edges].size).to eq(80) - - # As above, with a "good" surface PSI set - surfaces.each do |id, surface| - next unless surface[:boundary] == "outdoors" - - expect(surface).to_not have_key(:ratio) unless id == "Entryway Wall 5" - next unless id == "Entryway Wall 5" - - expect(surface[:heatloss]).to be_within(TOL).of(14.05) - end - end - - it "can process JSON surface KHI & PSI + building & edge (4)" do - translator = OpenStudio::OSVersion::VersionTranslator.new - TBD.clean! - - file = File.join(__dir__, "files/osms/out/seb2.osm") - path = OpenStudio::Path.new(file) - model = translator.loadModel(path) - expect(model).to_not be_empty - model = model.get + expect(io[:edges].size).to eq(77) - # Consider the plenum as UNCONDITIONED. - plnum = model.getSpaceByName("Level 0 Ceiling Plenum") - expect(plnum).to_not be_empty - plnum = plnum.get - expect(TBD.unconditioned?(plnum)).to be false + n_edges_at_grade = 0 + n_edges_as_balconies = 0 + n_edges_as_balconysills = 0 + n_edges_as_balconydoorsills = 0 + n_edges_as_concave_parapets = 0 + n_edges_as_convex_parapets = 0 + n_edges_as_concave_roofs = 0 + n_edges_as_convex_roofs = 0 + n_edges_as_rimjoists = 0 + n_edges_as_concave_rimjoists = 0 + n_edges_as_convex_rimjoists = 0 + n_edges_as_fenestrations = 0 + n_edges_as_heads = 0 + n_edges_as_sills = 0 + n_edges_as_jambs = 0 + n_edges_as_doorheads = 0 + n_edges_as_doorsills = 0 + n_edges_as_doorjambs = 0 + n_edges_as_skylightjambs = 0 + n_edges_as_concave_jambs = 0 + n_edges_as_convex_jambs = 0 + n_edges_as_corners = 0 + n_edges_as_concave_corners = 0 + n_edges_as_convex_corners = 0 + n_edges_as_transitions = 0 - key = "space_conditioning_category" - val = "Unconditioned" - expect(plnum.additionalProperties.hasFeature(key)).to be false - expect(plnum.additionalProperties.setFeature(key, val)).to be true - expect(TBD.plenum?(plnum)).to be true - expect(TBD.unconditioned?(plnum)).to be true - expect(TBD.setpoints(plnum)[:heating]).to be_nil - expect(TBD.setpoints(plnum)[:cooling]).to be_nil - expect(TBD.status).to be_zero + io[:edges].each do |edge| + expect(edge).to have_key(:type) - argh = {} - argh[:option ] = "compliant" # superseded by :building PSI set on file - argh[:io_path ] = File.join(__dir__, "../json/tbd_seb_n7.json") - argh[:schema_path] = File.join(__dir__, "../tbd.schema.json") + n_edges_at_grade += 1 if edge[:type] == :grade + n_edges_at_grade += 1 if edge[:type] == :gradeconcave + n_edges_at_grade += 1 if edge[:type] == :gradeconvex + n_edges_as_balconies += 1 if edge[:type] == :balcony + n_edges_as_balconies += 1 if edge[:type] == :balconyconcave + n_edges_as_balconies += 1 if edge[:type] == :balconyconvex + n_edges_as_balconysills += 1 if edge[:type] == :balconysill + n_edges_as_balconysills += 1 if edge[:type] == :balconysillconcave + n_edges_as_balconysills += 1 if edge[:type] == :balconysillconvex + n_edges_as_balconydoorsills += 1 if edge[:type] == :balconydoorsill + n_edges_as_balconydoorsills += 1 if edge[:type] == :balconydoorsillconcave + n_edges_as_balconydoorsills += 1 if edge[:type] == :balconydoorsillconvex + n_edges_as_concave_parapets += 1 if edge[:type] == :parapetconcave + n_edges_as_convex_parapets += 1 if edge[:type] == :parapetconvex + n_edges_as_concave_roofs += 1 if edge[:type] == :roofconcave + n_edges_as_convex_roofs += 1 if edge[:type] == :roofconvex + n_edges_as_rimjoists += 1 if edge[:type] == :rimjoist + n_edges_as_concave_rimjoists += 1 if edge[:type] == :rimjoistconcave + n_edges_as_convex_rimjoists += 1 if edge[:type] == :rimjoistconvex + n_edges_as_fenestrations += 1 if edge[:type] == :fenestration + n_edges_as_heads += 1 if edge[:type] == :head + n_edges_as_heads += 1 if edge[:type] == :headconcave + n_edges_as_heads += 1 if edge[:type] == :headconvex + n_edges_as_sills += 1 if edge[:type] == :sill + n_edges_as_sills += 1 if edge[:type] == :sillconcave + n_edges_as_sills += 1 if edge[:type] == :sillconvex + n_edges_as_jambs += 1 if edge[:type] == :jamb + n_edges_as_concave_jambs += 1 if edge[:type] == :jambconcave + n_edges_as_convex_jambs += 1 if edge[:type] == :jambconvex + n_edges_as_doorheads += 1 if edge[:type] == :doorhead + n_edges_as_doorsills += 1 if edge[:type] == :doorsill + n_edges_as_doorjambs += 1 if edge[:type] == :doorjamb + n_edges_as_skylightjambs += 1 if edge[:type] == :skylightjamb + n_edges_as_skylightjambs += 1 if edge[:type] == :skylightjambconvex + n_edges_as_corners += 1 if edge[:type] == :corner + n_edges_as_concave_corners += 1 if edge[:type] == :cornerconcave + n_edges_as_convex_corners += 1 if edge[:type] == :cornerconvex + n_edges_as_transitions += 1 if edge[:type] == :transition + end - json = TBD.process(model, argh) + expect(n_edges_at_grade ).to eq( 0) + expect(n_edges_as_balconies ).to eq( 2) + expect(n_edges_as_balconysills ).to eq( 0) + expect(n_edges_as_balconydoorsills ).to eq( 2) # ... no longer GlassDoors + expect(n_edges_as_concave_parapets ).to eq( 0) # 1x if parapet (not roof) + expect(n_edges_as_convex_parapets ).to eq( 0) # 11x if parapet (not roof) + expect(n_edges_as_concave_roofs ).to eq( 1) + expect(n_edges_as_convex_roofs ).to eq(11) + expect(n_edges_as_rimjoists ).to eq( 5) + expect(n_edges_as_concave_rimjoists).to eq( 5) + expect(n_edges_as_convex_rimjoists ).to eq(18) + expect(n_edges_as_fenestrations ).to eq( 0) + expect(n_edges_as_heads ).to eq( 0) + expect(n_edges_as_sills ).to eq( 0) + expect(n_edges_as_jambs ).to eq( 0) + expect(n_edges_as_concave_jambs ).to eq( 0) + expect(n_edges_as_convex_jambs ).to eq( 0) + expect(n_edges_as_doorheads ).to eq( 2) # GlassDoor != fenestration + expect(n_edges_as_doorjambs ).to eq( 4) # GlassDoor != fenestration + expect(n_edges_as_doorsills ).to eq( 0) # (2x balconydoorsills) + expect(n_edges_as_skylightjambs ).to eq( 1) # along 1" rooftop strip + expect(n_edges_as_corners ).to eq( 0) + expect(n_edges_as_concave_corners ).to eq( 4) + expect(n_edges_as_convex_corners ).to eq(12) + expect(n_edges_as_transitions ).to eq(10) + + # Re-do, without changing door surface types. + file = File.join(__dir__, "files/osms/in/loscrigno.osm") + path = OpenStudio::Path.new(file) + model = translator.loadModel(path) + expect(model).to_not be_empty + model = model.get + + argh = {option: "90.1.22|steel.m|default", parapet: false} + json = TBD.process(model, argh) expect(json).to be_a(Hash) expect(json).to have_key(:io) expect(json).to have_key(:surfaces) @@ -4040,49 +3659,85 @@ expect(TBD.status).to be_zero expect(TBD.logs).to be_empty expect(surfaces).to be_a Hash - expect(surfaces.size).to eq(56) + expect(surfaces.size).to eq(31) expect(io).to be_a(Hash) expect(io).to have_key(:edges) - expect(io[:edges].size).to eq(80) + expect(io[:edges].size).to eq(77) - # In the JSON file, the "Entry way 1" space "compliant" PSI set supersedes - # the default :building PSI set "(non thermal bridging)". The 3x walls below - # (4, 5 & 6) - part of "Entry way 1" - will inherit the "compliant" PSI set - # and hence their constructions will be derated. Exceptionally, Wall 5 has - # - in addition to a handful of point conductances - derating edges based on - # the "good" PSI set. Finally, edges between Wall 5 and its "Sub Surface 8" - # have their types overwritten (from :fenestration to :balcony), i.e. - # 0.8 W/K per m instead of 0.35 W/K per m. The latter is a weird one, but - # illustrates basic JSON functionality. A more realistic override: a switch - # between :corner to :fenestration (or vice versa) for corner windows. - surfaces.each do |id, surface| - walls = ["Entryway Wall 5", "Entryway Wall 6", "Entryway Wall 4"] - next unless surface[:boundary] == "outdoors" + # roof PSI : 0.02 W/K•m + # - parapet PSI : 0.26 W/K•m + # --------------------------- + # = delta PSI : -0.24 W/K•m + # + # e.g. East & West : reduction of 10.4m x -0.24 W/K•m = -2.496 W/K + # e.g. North : reduction of 36.6m x -0.24 W/K•m = -8.784 W/K + # + # Total length of roof/parapets : 11m + 2x 36.6m + 2x 10.4m = 105m + # ... 105m x -0.24 W/K•m = -25.2 W/K + expect(surfaces["g_E_wall" ][:heatloss]).to be_within(TOL).of( 1.80) # 4.3 = -2.5 + expect(surfaces["g_W_wall" ][:heatloss]).to be_within(TOL).of( 1.80) # 4.3 = -2.5 + expect(surfaces["g_N_wall" ][:heatloss]).to be_within(TOL).of( 7.17) # 15.95 = -8.8 + expect(surfaces["g_S1_wall" ][:heatloss]).to be_within(TOL).of( 1.08) # 1.87 = -0.8 + expect(surfaces["g_S2_wall" ][:heatloss]).to be_within(TOL).of( 0.08) # 1.04 = -1.0 + expect(surfaces["g_S3_wall" ][:heatloss]).to be_within(TOL).of( 5.07) # 8.19 = -3.1 - expect(surface).to have_key(:ratio) if walls.include?(id) - expect(surface).to_not have_key(:ratio) unless walls.include?(id) - next unless id == "Entryway Wall 5" + expect(surfaces["e_top" ][:heatloss]).to be_within(TOL).of( 0.11) # 1.32 = -1.2 + expect(surfaces["e_E_wall" ][:heatloss]).to be_within(TOL).of( 0.14) # 0.32 = -0.2 + expect(surfaces["e_W_wall" ][:heatloss]).to be_within(TOL).of( 0.14) # 0.32 = -0.2 + expect(surfaces["e_N_wall" ][:heatloss]).to be_within(TOL).of( 0.95) + expect(surfaces["e_S_wall" ][:heatloss]).to be_within(TOL).of( 0.37) # 0.85 = -0.5 + expect(surfaces["e_floor" ][:heatloss]).to be_within(TOL).of( 2.46) - expect(surface[:heatloss]).to be_within(TOL).of(15.62) - end - end + expect(surfaces["s_E_wall" ][:heatloss]).to be_within(TOL).of( 1.17) + expect(surfaces["s_W_wall" ][:heatloss]).to be_within(TOL).of( 1.17) + expect(surfaces["s_N_wall" ][:heatloss]).to be_within(TOL).of( 1.54) + expect(surfaces["s_S_wall" ][:heatloss]).to be_within(TOL).of( 1.54) + expect(surfaces["s_floor" ][:heatloss]).to be_within(TOL).of( 2.70) - it "can factor in negative PSI-factors (JSON input)" do - translator = OpenStudio::OSVersion::VersionTranslator.new + expect(surfaces["p_W1_floor"][:heatloss]).to be_within(TOL).of( 4.23) + expect(surfaces["p_W2_floor"][:heatloss]).to be_within(TOL).of( 1.82) + expect(surfaces["p_W3_floor"][:heatloss]).to be_within(TOL).of( 1.82) + expect(surfaces["p_W4_floor"][:heatloss]).to be_within(TOL).of( 0.58) + expect(surfaces["p_E_floor" ][:heatloss]).to be_within(TOL).of( 5.73) + expect(surfaces["p_N_wall" ][:heatloss]).to be_within(TOL).of(11.44) + expect(surfaces["p_S2_wall" ][:heatloss]).to be_within(TOL).of( 8.16) + expect(surfaces["p_S1_wall" ][:heatloss]).to be_within(TOL).of( 2.04) + expect(surfaces["p_floor" ][:heatloss]).to be_within(TOL).of( 3.07) + + expect(argh).to have_key(:io) + out = JSON.pretty_generate(argh[:io]) + outP = File.join(__dir__, "../json/tbd_loscrigno1.out.json") + File.open(outP, "w") { |outP| outP.puts out } + + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + # 4x cases (warehouse.osm): + # - 1x :parapet (default) case + # - 2x :roof case + # - 2x JSON variations TBD.clean! + ids = { a: "Office Front Wall", + b: "Office Left Wall", + c: "Fine Storage Roof", + d: "Fine Storage Office Front Wall", + e: "Fine Storage Office Left Wall", + f: "Fine Storage Front Wall", + g: "Fine Storage Left Wall", + h: "Fine Storage Right Wall", + i: "Bulk Storage Roof", + j: "Bulk Storage Rear Wall", + k: "Bulk Storage Left Wall", + l: "Bulk Storage Right Wall" }.freeze + + # CASE 1: :parapet (default) case. 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 - argh = {} - argh[:option ] = "compliant" # superseded by :building PSI set on file - argh[:io_path ] = File.join(__dir__, "../json/tbd_warehouse4.json") - argh[:schema_path] = File.join(__dir__, "../tbd.schema.json") - - json = TBD.process(model, argh) + argh = {option: "90.1.22|steel.m|default"} + json = TBD.process(model, argh) expect(json).to be_a(Hash) expect(json).to have_key(:io) expect(json).to have_key(:surfaces) @@ -4096,394 +3751,221 @@ expect(io).to have_key(:edges) expect(io[:edges].size).to eq(300) - ids = { a: "Office Front Wall", - b: "Office Left Wall", - c: "Fine Storage Roof", - d: "Fine Storage Office Front Wall", - e: "Fine Storage Office Left Wall", - f: "Fine Storage Front Wall", - g: "Fine Storage Left Wall", - h: "Fine Storage Right Wall", - i: "Bulk Storage Roof", - j: "Bulk Storage Rear Wall", - k: "Bulk Storage Left Wall", - l: "Bulk Storage Right Wall" }.freeze - surfaces.each do |id, surface| - expect(ids).to have_value(id) if surface.key?(:edges) + expect(ids).to have_value(id) if surface.key?(:edges) expect(ids).to_not have_value(id) unless surface.key?(:edges) end surfaces.each do |id, surface| - next unless surface[:boundary] == "outdoors" - next unless surface.key?(:ratio) + next unless surface.key?(:edges) expect(ids).to have_value(id) expect(surface).to have_key(:heatloss) + expect(surface).to have_key(:ratio) + h = surface[:heatloss] + s = model.getSurfaceByName(id) + expect(s).to_not be_empty + s = s.get + expect(s.nameString).to eq(id) + expect(s.isConstructionDefaulted).to be false + expect(s.construction.get.nameString).to include(" tbd") + expect(h).to be_within(TOL).of( 8.00) if id == ids[:a] # 50.20 if "poor" + expect(h).to be_within(TOL).of( 4.24) if id == ids[:b] # 24.06 if "poor" + expect(h).to be_within(TOL).of( 17.23) if id == ids[:c] # 87.16 ... + expect(h).to be_within(TOL).of( 6.53) if id == ids[:d] # 22.61 + expect(h).to be_within(TOL).of( 2.30) if id == ids[:e] # 9.15 + expect(h).to be_within(TOL).of( 1.95) if id == ids[:f] # 26.47 + expect(h).to be_within(TOL).of( 2.10) if id == ids[:g] # 27.19 + expect(h).to be_within(TOL).of( 3.00) if id == ids[:h] # 41.36 + expect(h).to be_within(TOL).of( 26.97) if id == ids[:i] # 161.02 + expect(h).to be_within(TOL).of( 5.25) if id == ids[:j] # 62.28 + expect(h).to be_within(TOL).of( 8.06) if id == ids[:k] # 117.87 + expect(h).to be_within(TOL).of( 8.06) if id == ids[:l] # 95.77 - # Ratios are typically negative e.g., a steel corner column decreasing - # linked surface RSi values. In some cases, a corner PSI can be positive - # (and thus increasing linked surface RSi values). This happens when - # estimating PSI-factors for convex corners while relying on an interior - # dimensioning convention e.g., BETBG Detail 7.6.2, ISO 14683. - expect(surface[:ratio]).to be_within(TOL).of(0.18) if id == ids[:a] - expect(surface[:ratio]).to be_within(TOL).of(0.55) if id == ids[:b] - expect(surface[:ratio]).to be_within(TOL).of(0.15) if id == ids[:d] - expect(surface[:ratio]).to be_within(TOL).of(0.43) if id == ids[:e] - expect(surface[:ratio]).to be_within(TOL).of(0.20) if id == ids[:f] - expect(surface[:ratio]).to be_within(TOL).of(0.13) if id == ids[:h] - expect(surface[:ratio]).to be_within(TOL).of(0.12) if id == ids[:j] - expect(surface[:ratio]).to be_within(TOL).of(0.04) if id == ids[:k] - expect(surface[:ratio]).to be_within(TOL).of(0.04) if id == ids[:l] - - # In such cases, negative heatloss means heat gained. - expect(surface[:heatloss]).to be_within(TOL).of(-0.10) if id == ids[:a] - expect(surface[:heatloss]).to be_within(TOL).of(-0.10) if id == ids[:b] - expect(surface[:heatloss]).to be_within(TOL).of(-0.10) if id == ids[:d] - expect(surface[:heatloss]).to be_within(TOL).of(-0.10) if id == ids[:e] - expect(surface[:heatloss]).to be_within(TOL).of(-0.20) if id == ids[:f] - expect(surface[:heatloss]).to be_within(TOL).of(-0.20) if id == ids[:h] - expect(surface[:heatloss]).to be_within(TOL).of(-0.40) if id == ids[:j] - expect(surface[:heatloss]).to be_within(TOL).of(-0.20) if id == ids[:k] - expect(surface[:heatloss]).to be_within(TOL).of(-0.20) if id == ids[:l] + 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.layers[1].nameString).to include("m tbd") end - end - it "can process JSON file read/validate" do - TBD.clean! + surfaces.each do |id, surface| + if surface.key?(:ratio) # ... vs "poor (BETBG)" + expect(surface[:ratio]).to be_within(0.2).of(-18.3) if id == ids[:b] # -53.0% + expect(surface[:ratio]).to be_within(0.2).of( -3.5) if id == ids[:c] # -15.6% + 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]).to_not eq("outdoors") + end + end - argh = {} - argh[:schema_path] = File.join(__dir__, "../tbd.schema.json") - argh[:io_path ] = File.join(__dir__, "../json/tbd_json_test.json") + # CASE 2: :roof (not default :parapet) case. + 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 - expect(File.exist?(argh[:schema_path])).to be true - schema = File.read(argh[:schema_path]) - schema = JSON.parse(schema, symbolize_names: true) - io = File.read(argh[:io_path]) - io = JSON.parse(io, symbolize_names: true) - expect(JSON::Validator.validate(schema, io)).to be true - expect(io).to have_key(:description) - expect(io).to have_key(:schema) + argh = {option: "90.1.22|steel.m|default", parapet: false} + 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.status).to be_zero + expect(TBD.logs).to be_empty + expect(surfaces).to be_a Hash + expect(surfaces.size).to eq(23) + expect(io).to be_a(Hash) expect(io).to have_key(:edges) - expect(io).to have_key(:surfaces) - expect(io).to have_key(:building) - expect(io).to_not have_key(:spaces) - expect(io).to_not have_key(:spacetypes) - expect(io).to_not have_key(:stories) - expect(io).to_not have_key(:logs) - expect(io[:edges].size ).to eq(1) - expect(io[:surfaces].size).to eq(1) - - # Loop through input psis to ensure uniqueness vs PSI defaults. - psi = TBD::PSI.new - expect(io).to have_key(:psis) - - io[:psis].each { |p| expect(psi.append(p)).to be true } - - expect(psi.set.size).to eq(18) - expect(psi.set).to have_key("poor (BETBG)") - expect(psi.set).to have_key("regular (BETBG)") - expect(psi.set).to have_key("efficient (BETBG)") - expect(psi.set).to have_key("spandrel (BETBG)") - expect(psi.set).to have_key("spandrel HP (BETBG)") - expect(psi.set).to have_key("code (Quebec)") - expect(psi.set).to have_key("uncompliant (Quebec)") - expect(psi.set).to have_key("90.1.22|steel.m|default") - expect(psi.set).to have_key("90.1.22|steel.m|unmitigated") - expect(psi.set).to have_key("90.1.22|mass.ex|default") - expect(psi.set).to have_key("90.1.22|mass.ex|unmitigated") - expect(psi.set).to have_key("90.1.22|mass.in|default") - expect(psi.set).to have_key("90.1.22|mass.in|unmitigated") - expect(psi.set).to have_key("90.1.22|wood.fr|default") - expect(psi.set).to have_key("90.1.22|wood.fr|unmitigated") - expect(psi.set).to have_key("(non thermal bridging)") - expect(psi.set).to have_key("good") # appended - expect(psi.set).to have_key("compliant") # appended - - # Similar treatment for khis. - khi = TBD::KHI.new - expect(io).to have_key(:khis) - - io[:khis].each { |k| expect(khi.append(k)).to be true } - - expect(khi.point.size).to eq(16) - expect(khi.point).to have_key("poor (BETBG)") - expect(khi.point).to have_key("regular (BETBG)") - expect(khi.point).to have_key("efficient (BETBG)") - expect(khi.point).to have_key("code (Quebec)") - expect(khi.point).to have_key("uncompliant (Quebec)") - expect(khi.point).to have_key("90.1.22|steel.m|default") - expect(khi.point).to have_key("90.1.22|steel.m|unmitigated") - expect(khi.point).to have_key("90.1.22|mass.ex|default") - expect(khi.point).to have_key("90.1.22|mass.ex|unmitigated") - expect(khi.point).to have_key("90.1.22|mass.in|default") - expect(khi.point).to have_key("90.1.22|mass.in|unmitigated") - expect(khi.point).to have_key("90.1.22|wood.fr|default") - expect(khi.point).to have_key("90.1.22|wood.fr|unmitigated") - expect(khi.point).to have_key("(non thermal bridging)") - expect(khi.point).to have_key("column") # appended - expect(khi.point).to have_key("support") # appended + expect(io[:edges].size).to eq(300) - expect(khi.point["column" ]).to eq(0.5) - expect(khi.point["support"]).to eq(0.5) + surfaces.each do |id, surface| + expect(ids).to have_value(id) if surface.key?(:edges) + expect(ids).to_not have_value(id) unless surface.key?(:edges) + end - expect(psi.set).to have_key("spandrel (BETBG)") - expect(psi.set).to have_key("spandrel HP (BETBG)") + surfaces.each do |id, surface| + next unless surface.key?(:edges) - expect(io).to have_key(:building) - expect(io).to have_key(:surfaces) - expect(io[:building]).to have_key(:psi) - expect(io[:building][:psi]).to eq("compliant") - expect(psi.set).to have_key(io[:building][:psi]) + expect(ids).to have_value(id) + expect(surface).to have_key(:heatloss) + expect(surface).to have_key(:ratio) + h = surface[:heatloss] + s = model.getSurfaceByName(id) + expect(s).to_not be_empty + s = s.get + expect(s.nameString).to eq(id) + expect(s.isConstructionDefaulted).to be false + expect(s.construction.get.nameString).to include(" tbd") + expect(h).to be_within(TOL).of( 8.00) if id == ids[:a] # 8.00 ! + expect(h).to be_within(TOL).of( 4.24) if id == ids[:b] # 4.24 ! + expect(h).to be_within(TOL).of( 1.33) if id == ids[:c] # 17.23 + expect(h).to be_within(TOL).of( 4.17) if id == ids[:d] # 6.53 + expect(h).to be_within(TOL).of( 1.47) if id == ids[:e] # 2.30 + expect(h).to be_within(TOL).of( 0.15) if id == ids[:f] # 1.95 + expect(h).to be_within(TOL).of( 0.16) if id == ids[:g] # 2.10 + expect(h).to be_within(TOL).of( 0.23) if id == ids[:h] # 3.00 + expect(h).to be_within(TOL).of( 2.07) if id == ids[:i] # 26.97 + expect(h).to be_within(TOL).of( 0.40) if id == ids[:j] # 5.25 + expect(h).to be_within(TOL).of( 0.62) if id == ids[:k] # 8.06 + expect(h).to be_within(TOL).of( 0.62) if id == ids[:l] # 8.06 + # ! office walls: same results ... no parapet/roof - io[:surfaces].each do |surface| - expect(surface).to have_key(:id) - expect(surface).to have_key(:psi) - expect(surface).to have_key(:khis) - expect(surface[:id ]).to eq("front wall") - expect(surface[:psi ]).to eq("good") - expect(surface[:khis].size).to eq(2) - expect(psi.set).to have_key(surface[:psi]) + 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.layers[1].nameString).to include("m tbd") + end - surface[:khis].each do |k| - expect(k).to have_key(:id) - expect(khi.point).to have_key(k[:id]) - expect(k[:count]).to eq(3) if k[:id] == "column" - expect(k[:count]).to eq(4) if k[:id] == "support" + surfaces.each do |id, surface| + if surface.key?(:ratio) # ... vs "parapet" + expect(surface[:ratio]).to be_within(0.2).of(-18.3) if id == ids[:b] # ! + expect(surface[:ratio]).to be_within(0.2).of( -0.3) if id == ids[:c] # -3.5% + expect(surface[:ratio]).to be_within(0.2).of( -0.1) if id == ids[:i] # -1.3% + 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]).to_not eq("outdoors") end end - expect(io).to have_key(:edges) - - io[:edges].each do |edge| - expect(edge).to have_key(:surfaces) - expect(edge).to have_key(:psi) - expect(edge[:psi]).to eq("compliant") - expect(psi.set).to have_key(edge[:psi]) + # CASE 3: Same as CASE 1 (:parapet), yet reset to :roof for "Bulk Storage" + # via JSON file. Extra surface-specific heat loss from derating will switch + # between CASE 1 vs CASE 2 values. + 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 - edge[:surfaces].each { |surface| expect(surface).to eq("front wall") } - end + argh = {} + argh[:option ] = "90.1.22|steel.m|default" + argh[:io_path ] = File.join(__dir__, "../json/tbd_warehouse17.json") + argh[:schema_path] = File.join(__dir__, "../tbd.schema.json") - # A reminder that built-in KHIs are not frozen ... - khi.point["code (Quebec)"] = 2.0 - expect(khi.point["code (Quebec)"]).to eq(2.0) + 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.status).to be_zero + expect(TBD.logs).to be_empty + expect(surfaces).to be_a Hash + expect(surfaces.size).to eq(23) + expect(io).to be_a(Hash) + expect(io).to have_key(:edges) + expect(io[:edges].size).to eq(300) - # Load PSI combo JSON example - likely the most expected or common use. - argh[:io_path] = File.join(__dir__, "../json/tbd_PSI_combo.json") + surfaces.each do |id, surface| + expect(ids).to have_value(id) if surface.key?(:edges) + expect(ids).to_not have_value(id) unless surface.key?(:edges) + end - io = File.read(argh[:io_path]) - io = JSON.parse(io, symbolize_names: true) - expect(JSON::Validator.validate(schema, io)).to be true - expect(io).to have_key(:description) - expect(io).to have_key(:schema) - expect(io).to have_key(:spaces) - expect(io).to have_key(:building) - expect(io).to_not have_key(:spacetypes) - expect(io).to_not have_key(:stories) - expect(io).to_not have_key(:edges) - expect(io).to_not have_key(:surfaces) - expect(io).to_not have_key(:logs) - expect(io[:spaces].size).to eq(1) + surfaces.each do |id, surface| + next unless surface.key?(:edges) - # Loop through input psis to ensure uniqueness vs PSI defaults. - psi = TBD::PSI.new - expect(io).to have_key(:psis) - - io[:psis].each { |p| expect(psi.append(p)).to be true } - - expect(psi.set.size).to eq(18) - expect(psi.set).to have_key("poor (BETBG)") - expect(psi.set).to have_key("regular (BETBG)") - expect(psi.set).to have_key("efficient (BETBG)") - expect(psi.set).to have_key("spandrel (BETBG)") - expect(psi.set).to have_key("spandrel HP (BETBG)") - expect(psi.set).to have_key("code (Quebec)") - expect(psi.set).to have_key("uncompliant (Quebec)") - expect(psi.set).to have_key("90.1.22|steel.m|default") - expect(psi.set).to have_key("90.1.22|steel.m|unmitigated") - expect(psi.set).to have_key("90.1.22|mass.ex|default") - expect(psi.set).to have_key("90.1.22|mass.ex|unmitigated") - expect(psi.set).to have_key("90.1.22|mass.in|default") - expect(psi.set).to have_key("90.1.22|mass.in|unmitigated") - expect(psi.set).to have_key("90.1.22|wood.fr|default") - expect(psi.set).to have_key("90.1.22|wood.fr|unmitigated") - expect(psi.set).to have_key("(non thermal bridging)") - expect(psi.set).to have_key("OK") # appended - expect(psi.set).to have_key("Awesome") # appended - - expect(psi.set["Awesome"][:rimjoist]).to eq(0.2) - expect(io).to have_key(:building) - expect(io[:building]).to have_key(:psi) - expect(io[:building][:psi]).to eq("Awesome") - expect(psi.set).to have_key(io[:building][:psi]) - expect(io).to have_key(:spaces) - - io[:spaces].each do |space| - expect(space).to have_key(:psi) - expect(space[:id ]).to eq("ground-floor restaurant") - expect(space[:psi]).to eq("OK") - expect(psi.set).to have_key(space[:psi]) - end - - # Load PSI combo2 JSON example - a more elaborate example, yet common. - # Post-JSON validation required to handle case sensitive keys & value - # strings (e.g. "ok" vs "OK" in the file). - argh[:io_path] = File.join(__dir__, "../json/tbd_PSI_combo2.json") - - io = File.read(argh[:io_path]) - io = JSON.parse(io, symbolize_names: true) - expect(JSON::Validator.validate(schema, io)).to be true - expect(io).to have_key(:description) - expect(io).to have_key(:schema) - expect(io).to have_key(:edges) - expect(io).to have_key(:surfaces) - expect(io).to have_key(:building) - expect(io).to_not have_key(:spaces) - expect(io).to_not have_key(:spacetypes) - expect(io).to_not have_key(:stories) - expect(io).to_not have_key(:logs) - expect(io[:edges ].size).to eq(1) - expect(io[:surfaces].size).to eq(1) - - # Loop through input psis to ensure uniqueness vs PSI defaults. - psi = TBD::PSI.new - expect(io).to have_key(:psis) - - io[:psis].each { |pzi| expect(psi.append(pzi)).to be true } - - expect(psi.set.size).to eq(19) - expect(psi.set).to have_key("poor (BETBG)") - expect(psi.set).to have_key("regular (BETBG)") - expect(psi.set).to have_key("efficient (BETBG)") - expect(psi.set).to have_key("spandrel (BETBG)") - expect(psi.set).to have_key("spandrel HP (BETBG)") - expect(psi.set).to have_key("code (Quebec)") - expect(psi.set).to have_key("uncompliant (Quebec)") - expect(psi.set).to have_key("90.1.22|steel.m|default") - expect(psi.set).to have_key("90.1.22|steel.m|unmitigated") - expect(psi.set).to have_key("90.1.22|mass.ex|default") - expect(psi.set).to have_key("90.1.22|mass.ex|unmitigated") - expect(psi.set).to have_key("90.1.22|mass.in|default") - expect(psi.set).to have_key("90.1.22|mass.in|unmitigated") - expect(psi.set).to have_key("90.1.22|wood.fr|default") - expect(psi.set).to have_key("90.1.22|wood.fr|unmitigated") - expect(psi.set).to have_key("(non thermal bridging)") - expect(psi.set).to have_key("OK") # appended - expect(psi.set).to have_key("Awesome") # appended - expect(psi.set).to have_key("Party wall edge") # appended - - expect(psi.set["Party wall edge"][:party]).to eq(0.4) - expect(io).to have_key(:surfaces) - expect(io).to have_key(:building) - expect(io[:building]).to have_key(:psi) - expect(io[:building][:psi]).to eq("Awesome") - expect(psi.set).to have_key(io[:building][:psi]) + expect(ids).to have_value(id) + expect(surface).to have_key(:heatloss) + expect(surface).to have_key(:ratio) + h = surface[:heatloss] + s = model.getSurfaceByName(id) + expect(s).to_not be_empty + s = s.get + expect(s.nameString).to eq(id) + expect(s.isConstructionDefaulted).to be false + expect(s.construction.get.nameString).to include(" tbd") + expect(h).to be_within(TOL).of( 8.00) if id == ids[:a] # ! + expect(h).to be_within(TOL).of( 4.24) if id == ids[:b] # ! + expect(h).to be_within(TOL).of( 17.23) if id == ids[:c] + expect(h).to be_within(TOL).of( 6.53) if id == ids[:d] + expect(h).to be_within(TOL).of( 2.30) if id == ids[:e] + expect(h).to be_within(TOL).of( 1.95) if id == ids[:f] + expect(h).to be_within(TOL).of( 2.10) if id == ids[:g] + expect(h).to be_within(TOL).of( 3.00) if id == ids[:h] + expect(h).to be_within(TOL).of( 2.07) if id == ids[:i] # Bulk + expect(h).to be_within(TOL).of( 0.40) if id == ids[:j] # Bulk + expect(h).to be_within(TOL).of( 0.62) if id == ids[:k] # Bulk + expect(h).to be_within(TOL).of( 0.62) if id == ids[:l] # Bulk + # ! office walls: same results ... no parapet/roof - io[:surfaces].each do |surface| - expect(surface).to have_key(:id) - expect(surface).to have_key(:psi) - expect(surface[:id ]).to eq("ground-floor restaurant South-wall") - expect(surface[:psi]).to eq("ok") - expect(psi.set).to_not have_key(surface[:psi]) + 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.layers[1].nameString).to include("m tbd") end - expect(io).to have_key(:edges) - wlls = [] - wlls << "ground-floor restaurant West-wall" - wlls << "ground-floor restaurant party wall" - - io[:edges].each do |edge| - expect(edge).to have_key(:type) - expect(edge).to have_key(:psi) - expect(edge[:psi]).to eq("Party wall edge") - expect(edge[:type].to_s).to include("party") - expect(psi.set).to have_key(edge[:psi]) - expect(psi.set[edge[:psi]]).to have_key(:party) - expect(edge).to have_key(:surfaces) - - edge[:surfaces].each { |surface| expect(wlls).to include(surface) } + surfaces.each do |id, surface| + if surface.key?(:ratio) + expect(surface[:ratio]).to be_within(0.2).of(-18.3) if id == ids[:b] # ! + expect(surface[:ratio]).to be_within(0.2).of( -3.5) if id == ids[:c] + expect(surface[:ratio]).to be_within(0.2).of( -0.1) if id == ids[:i] # Bulk + 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]).to_not eq("outdoors") + end end - # Load full PSI JSON example - with duplicate keys for "party". - # "JSON Schema Lint" (*) will recognize the duplicate and - as with - # duplicate Ruby hash keys - will have the second entry ("party": 0.8) - # override the first ("party": 0.7). Another reminder of post-JSON - # validation. + # CASE 4: Same as CASE 3 (:parapet, reset to :roof for "Bulk Storage" + # via JSON file), yet wall/roof edge along "Bulk Storage Rear Wall", + # ids[:j], is reset to :parapet (via JSON file). Again, extra surface + # -specific heat loss from derating will switch between CASE 1 vs CASE 2 + # values (either one or the other). Exceptionally in the case of the "Bulk + # Storage Roof", the extra heat loss (and derating %) are greater somewhat + # (vs CASE 3), as it remains affected by the (unaltered) :roof edges along: + # + # - "Bulk Storage Left Wall" + # - "Bulk Storage Right Wall" # - # * https://jsonschemalint.com/#!/version/draft-04/markup/json - argh[:io_path] = File.join(__dir__, "../json/tbd_full_PSI.json") - - io = File.read(argh[:io_path]) - io = JSON.parse(io, symbolize_names: true) - expect(JSON::Validator.validate(schema, io)).to be true - - expect(io).to have_key(:description) - expect(io).to have_key(:schema) - expect(io).to_not have_key(:edges) - expect(io).to_not have_key(:surfaces) - expect(io).to_not have_key(:spaces) - expect(io).to_not have_key(:spacetypes) - expect(io).to_not have_key(:stories) - expect(io).to_not have_key(:building) - expect(io).to_not have_key(:logs) - - # Loop through input psis to ensure uniqueness vs PSI defaults. - psi = TBD::PSI.new - expect(io).to have_key(:psis) - - io[:psis].each { |p| expect(psi.append(p)).to be true } - - expect(psi.set.size).to eq(17) - expect(psi.set).to have_key("poor (BETBG)") - expect(psi.set).to have_key("regular (BETBG)") - expect(psi.set).to have_key("efficient (BETBG)") - expect(psi.set).to have_key("spandrel (BETBG)") - expect(psi.set).to have_key("spandrel HP (BETBG)") - expect(psi.set).to have_key("code (Quebec)") - expect(psi.set).to have_key("uncompliant (Quebec)") - expect(psi.set).to have_key("90.1.22|steel.m|default") - expect(psi.set).to have_key("90.1.22|steel.m|unmitigated") - expect(psi.set).to have_key("90.1.22|mass.ex|default") - expect(psi.set).to have_key("90.1.22|mass.ex|unmitigated") - expect(psi.set).to have_key("90.1.22|mass.in|default") - expect(psi.set).to have_key("90.1.22|mass.in|unmitigated") - expect(psi.set).to have_key("90.1.22|wood.fr|default") - expect(psi.set).to have_key("90.1.22|wood.fr|unmitigated") - expect(psi.set).to have_key("(non thermal bridging)") - expect(psi.set).to have_key("OK") # appended - - expect(psi.set["OK"][:party]).to eq(0.8) - - # Load minimal PSI JSON example. - argh[:io_path] = File.join(__dir__, "../json/tbd_minimal_PSI.json") - - io = File.read(argh[:io_path]) - io = JSON.parse(io, symbolize_names: true) - expect(JSON::Validator.validate(schema, io)).to be true - - # Load minimal KHI JSON example. - argh[:io_path] = File.join(__dir__, "../json/tbd_minimal_KHI.json") - - io = File.read(argh[:io_path]) - io = JSON.parse(io, symbolize_names: true) - expect(JSON::Validator.validate(schema, io)).to be true - v = JSON::Validator.validate(argh[:schema_path], argh[:io_path], uri: true) - expect(v).to be true - - # Load complete results (ex. UA') example. - argh[:io_path] = File.join(__dir__, "../json/tbd_warehouse11.json") - - io = File.read(argh[:io_path]) - io = JSON.parse(io, symbolize_names: true) - expect(JSON::Validator.validate(schema, io)).to be true - v = JSON::Validator.validate(argh[:schema_path], argh[:io_path], uri: true) - expect(v).to be true - end - - it "can factor in spacetype-specific PSI sets (JSON input)" do - translator = OpenStudio::OSVersion::VersionTranslator.new - TBD.clean! - file = File.join(__dir__, "files/osms/in/warehouse.osm") path = OpenStudio::Path.new(file) model = translator.loadModel(path) @@ -4491,8 +3973,8 @@ model = model.get argh = {} - argh[:option ] = "compliant" # superseded by :building PSI set on file - argh[:io_path ] = File.join(__dir__, "../json/tbd_warehouse5.json") + argh[:option ] = "90.1.22|steel.m|default" + argh[:io_path ] = File.join(__dir__, "../json/tbd_warehouse18.json") argh[:schema_path] = File.join(__dir__, "../tbd.schema.json") json = TBD.process(model, argh) @@ -4503,42 +3985,66 @@ surfaces = json[:surfaces] expect(TBD.status).to be_zero expect(TBD.logs).to be_empty - expect(surfaces).to be_a(Hash) + expect(surfaces).to be_a Hash expect(surfaces.size).to eq(23) expect(io).to be_a(Hash) expect(io).to have_key(:edges) expect(io[:edges].size).to eq(300) - types = ["Warehouse Office", "Warehouse Fine"] - expect(io).to have_key(:spacetypes) - - io[:spacetypes].each do |spacetype| - expect(spacetype).to have_key(:psi) - expect(spacetype).to have_key(:id) - expect(types).to include(spacetype[:id]) + surfaces.each do |id, surface| + expect(ids).to have_value(id) if surface.key?(:edges) + expect(ids).to_not have_value(id) unless surface.key?(:edges) end surfaces.each do |id, surface| - next unless surface[:boundary] == "outdoors" - next unless surface.key?(:ratio) + next unless surface.key?(:edges) + expect(ids).to have_value(id) expect(surface).to have_key(:heatloss) - heatloss = surface[:heatloss] - expect(heatloss.abs).to be > 0 - expect(surface).to have_key(:space) - next unless surface[:space].nameString == "Zone1 Office" + expect(surface).to have_key(:ratio) + h = surface[:heatloss] + s = model.getSurfaceByName(id) + expect(s).to_not be_empty + s = s.get + expect(s.nameString).to eq(id) + expect(s.isConstructionDefaulted).to be false + expect(s.construction.get.nameString).to include(" tbd") + expect(h).to be_within(TOL).of( 8.00) if id == ids[:a] # ! + expect(h).to be_within(TOL).of( 4.24) if id == ids[:b] # ! + expect(h).to be_within(TOL).of( 17.23) if id == ids[:c] + expect(h).to be_within(TOL).of( 6.53) if id == ids[:d] + expect(h).to be_within(TOL).of( 2.30) if id == ids[:e] + expect(h).to be_within(TOL).of( 1.95) if id == ids[:f] + expect(h).to be_within(TOL).of( 2.10) if id == ids[:g] + expect(h).to be_within(TOL).of( 3.00) if id == ids[:h] + expect(h).to be_within(TOL).of( 8.20) if id == ids[:i] # 2.07 < x < 26.97 + expect(h).to be_within(TOL).of( 5.25) if id == ids[:j] # Bulk Rear Wall + expect(h).to be_within(TOL).of( 0.62) if id == ids[:k] # Bulk + expect(h).to be_within(TOL).of( 0.62) if id == ids[:l] # Bulk + # ! office walls: same results ... no parapet/roof - # All applicable thermal bridges/edges derating the office walls inherit - # the "Warehouse Office" spacetype PSI values (JSON file), except for the - # shared :rimjoist with the Fine Storage space above. The "Warehouse Fine" - # spacetype set has a higher :rimjoist PSI value of 0.5 W/K per metre, - # which overrides the "Warehouse Office" value of 0.3 W/K per metre. - expect(heatloss).to be_within(TOL).of(11.61) if id == "Office Left Wall" - expect(heatloss).to be_within(TOL).of(22.94) if id == "Office Front Wall" + 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.layers[1].nameString).to include("m tbd") + end + + surfaces.each do |id, surface| + if surface.key?(:ratio) + expect(surface[:ratio]).to be_within(0.2).of(-18.3) if id == ids[:b] # ! + expect(surface[:ratio]).to be_within(0.2).of( -3.5) if id == ids[:c] + expect(surface[:ratio]).to be_within(0.2).of( -0.4) if id == ids[:i] # 0.1 < x < 1.3% + 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]).to_not eq("outdoors") + end end end - it "can factor in story-specific PSI sets (JSON input)" do + it "can process DOE Prototype smalloffice.osm" do translator = OpenStudio::OSVersion::VersionTranslator.new TBD.clean! @@ -4548,57 +4054,94 @@ expect(model).to_not be_empty model = model.get - argh = {} - argh[:option ] = "compliant" # superseded by :building PSI set on file - argh[:io_path ] = File.join(__dir__, "../json/tbd_smalloffice.json") - argh[:schema_path] = File.join(__dir__, "../tbd.schema.json") + model.getSpaces.each do |space| + expect(space.thermalZone).to_not be_empty + zone = space.thermalZone.get - 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.status).to be_zero - expect(TBD.logs).to be_empty - expect(surfaces).to be_a(Hash) - expect(surfaces.size).to eq(43) - expect(io).to be_a(Hash) - expect(io).to have_key(:stories) - expect(io).to have_key(:edges) - expect(io[:edges].size).to eq(105) + heat_spt = TBD.maxHeatScheduledSetpoint(zone) + cool_spt = TBD.minCoolScheduledSetpoint(zone) + expect(heat_spt).to have_key(:spt) + expect(cool_spt).to have_key(:spt) - io[:stories].each do |story| - expect(story).to have_key(:psi) - expect(story).to have_key(:id) - expect(story[:id]).to eq("Building Story 1") + heating = heat_spt[:spt] + cooling = cool_spt[:spt] + stpts = TBD.setpoints(space) + expect(stpts).to have_key(:heating) + expect(stpts).to have_key(:cooling) + + if zone.nameString == "Attic ZN" + expect(heating).to be_nil + expect(cooling).to be_nil + expect(stpts[:heating]).to be_nil + expect(stpts[:cooling]).to be_nil + expect(zone.isPlenum).to be false + expect(TBD.plenum?(space)).to be false + next + end + + expect(TBD.plenum?(space)).to be false + expect(heating).to be_within(0.1).of(21.1) + expect(cooling).to be_within(0.1).of(23.9) + expect(stpts[:heating]).to_not be_nil + expect(stpts[:cooling]).to_not be_nil + expect(stpts[:heating]).to be_within(0.1).of(21.1) + expect(stpts[:cooling]).to be_within(0.1).of(23.9) end - surfaces.each do |id, surface| - next unless surface.key?(:ratio) + # Tracking insulated ceiling surfaces below attic. + model.getSurfaces.each do |s| + next unless s.surfaceType == "RoofCeiling" + next unless s.isConstructionDefaulted - expect(surface).to have_key(:heatloss) - expect(surface[:heatloss].abs).to be > 0 - next unless surface.key?(:story) + c = s.construction + expect(c).to_not be_empty + c = c.get.to_LayeredConstruction + expect(c).to_not be_empty + c = c.get - expect(surface[:story].nameString).to eq("Building Story 1") + id = c.nameString + expect(id).to eq("Typical Wood Joist Attic Floor R-37.04 1") + expect(c.layers.size).to eq(2) + expect(c.layers[0].nameString).to eq("5/8 in. Gypsum Board") + expect(c.layers[1].nameString).to eq("Typical Insulation R-35.4 1") + # "5/8 in. Gypsum Board" : RSi = 0,0994 m2.K/W + # "Typical Insulation R-35.4 1" : RSi = 6,2348 m2.K/W end - end - it "can sort multiple story-specific PSI sets (JSON input)" do - translator = OpenStudio::OSVersion::VersionTranslator.new - TBD.clean! + # Tracking outdoor-facing office walls. + model.getSurfaces.each do |s| + next unless s.surfaceType == "Wall" + next unless s.outsideBoundaryCondition.downcase == "outdoors" - file = File.join(__dir__, "files/osms/in/midrise.osm") - path = OpenStudio::Path.new(file) - model = translator.loadModel(path) - expect(model).to_not be_empty - model = model.get + id = s.construction.get.nameString + str = "Typical Insulated Wood Framed Exterior Wall R-11.24" + expect(id).to include(str) - argh = {} - argh[:option ] = "(non thermal bridging)" # overridden - argh[:io_path ] = File.join(__dir__, "../json/midrise.json") - argh[:schema_path] = File.join(__dir__, "../tbd.schema.json") + 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.layers.size).to eq(4) + expect(c.layers[0].nameString).to eq("25mm Stucco") + expect(c.layers[1].nameString).to eq("5/8 in. Gypsum Board") + str2 = "Typical Insulation R-9.06 1" + expect(c.layers[2].nameString).to include(str2) + expect(c.layers[3].nameString).to eq("5/8 in. Gypsum Board") + # "25mm Stucco" : RSi = 0,0353 m2.K/W + # "5/8 in. Gypsum Board" : RSi = 0,0994 m2.K/W + # "Perimeter_ZN_1_wall_south Typical Insulation R-9.06 1" + # : RSi = 0,5947 m2.K/W + # "Perimeter_ZN_2_wall_east Typical Insulation R-9.06 1" + # : RSi = 0,6270 m2.K/W + # "Perimeter_ZN_3_wall_north Typical Insulation R-9.06 1" + # : RSi = 0,6346 m2.K/W + # "Perimeter_ZN_4_wall_west Typical Insulation R-9.06 1" + # : RSi = 0,6270 m2.K/W + end + + argh = { option: "poor (BETBG)" } json = TBD.process(model, argh) expect(json).to be_a(Hash) @@ -4609,185 +4152,128 @@ expect(TBD.status).to be_zero expect(TBD.logs).to be_empty expect(surfaces).to be_a(Hash) - expect(surfaces.size).to eq(180) + expect(surfaces.size).to eq(43) expect(io).to be_a(Hash) expect(io).to have_key(:edges) - expect(io[:edges].size).to eq(282) + expect(io[:edges].size).to eq(105) - counter = 0 - stories = ["Building Story 1", "Building Story 2", "Building Story 3"] - edges = [:parapetconvex, :transition] - expect(io).to have_key(:stories) - expect(io[:stories].size).to eq(stories.size) + surfaces.each do |id, surface| + expect(surface).to have_key(:conditioned) + next unless surface[:conditioned] - io[:stories].each do |story| - expect(story).to have_key(:id) - expect(story).to have_key(:psi) - expect(stories).to include(story[:id]) + expect(surface).to have_key(:heating) + expect(surface).to have_key(:cooling) + + # Testing glass door detection + if surface.key?(:doors) + surface[:doors].each do |i, door| + expect(door).to have_key(:glazed) + expect(door).to have_key(:u) + expect(door[:glazed]).to be true + expect(door[:u ]).to be_a(Numeric) + expect(door[:u ]).to be_within(TOL).of(6.40) + end + end end + # Testing attic surfaces. surfaces.each do |id, surface| - next unless surface.key?(:ratio) - - expect(surface).to have_key(:edges) - expect(surface).to have_key(:story) - expect(surface).to have_key(:boundary) - expect(surface[:boundary]).to eq("outdoors") - nom = surface[:story].nameString - expect(stories).to include(nom) - expect(nom).to eq(stories[0]) if id.include?("g ") - expect(nom).to eq(stories[1]) if id.include?("m ") - expect(nom).to eq(stories[2]) if id.include?("t ") + expect(surface).to have_key(:space) + next unless surface[:space].nameString == "Attic" - counter += 1 + # Attic is an UNENCLOSED zone - outdoor-facing surfaces are not derated. + expect(surface).to have_key(:filmRSI) + expect(surface).to have_key(:conditioned) + expect(surface[:conditioned]).to be false + expect(surface).to_not have_key(:heatloss) + expect(surface).to_not have_key(:ratio) - # Illustrating that story-specific PSI set is used when only 1x story. - surface[:edges].values.each do |edge| - expect(edge).to have_key(:type) - expect(edge).to have_key(:psi) - next unless id.include?("Roof") + # Attic floor surfaces adjacent to ceiling surfaces below (CONDITIONED + # office spaces) share derated constructions (although inverted). + expect(surface).to have_key(:boundary) + b = surface[:boundary] + next if b == "outdoors" - expect(edges).to include(edge[:type]) - next if edge[:type] == :transition - next if id == "t Roof C" + # TBD/Topolys should be tracking the adjacent CONDITIONED surface. + expect(surfaces).to have_key(b) + expect(surfaces[b]).to be_a(Hash) + expect(surfaces[b]).to have_key(:conditioned) + expect(surfaces[b][:conditioned]).to be true - expect(edge[:psi]).to be_within(TOL).of(0.178) # 57.3% of 0.311 + if id == "Attic_floor_core" + expect(surfaces[b]).to_not have_key(:ratio) + expect(surfaces[b]).to have_key(:heatloss) + expect(surfaces[b][:heatloss]).to be_within(TOL).of(0.00) end - # Illustrating that story-specific PSI set is used when only 1x story. - surface[:edges].values.each do |edge| - next unless id.include?("t ") - next unless id.include?("Wall ") - next unless edge[:type] == :parapetconvex - next if id.include?(" C") + next if id == "Attic_floor_core" - expect(edge[:psi]).to be_within(TOL).of(0.133) # 42.7% of 0.311 - end - - # The shared :rimjoist between middle story and ground floor units could - # either inherit the "Building Story 1" or "Building Story 2" :rimjoist - # PSI values. TBD retains the most conductive PSI values in such cases. - surface[:edges].values.each do |edge| - next unless id.include?("m ") - next unless id.include?("Wall ") - next if id.include?(" C") - next unless edge[:type] == :rimjoist - - # Inheriting "Building Story 1" :rimjoist PSI of 0.501 W/K per metre. - # The SEA unit is above an office space below, which has curtain wall. - # RSi of insulation layers (to derate): - # - office walls : 0.740 m2.K/W (26.1%) - # - SEA walls : 2.100 m2.K/W (73.9%) - # - # - SEA walls : 26.1% of 0.501 = 0.3702 W/K per metre - # - other walls : 50.0% of 0.501 = 0.2505 W/K per metre - if ["m SWall SEA", "m EWall SEA"].include?(id) - expect(edge[:psi]).to be_within(0.002).of(0.3702) - else - expect(edge[:psi]).to be_within(0.002).of(0.2505) - end - end - end - - expect(counter).to eq(51) - end - - it "can handle parties" do - translator = OpenStudio::OSVersion::VersionTranslator.new - TBD.clean! - - file = File.join(__dir__, "files/osms/out/seb2.osm") - path = OpenStudio::Path.new(file) - model = translator.loadModel(path) - expect(model).to_not be_empty - model = model.get - - # Consider the plenum as UNCONDITIONED. - plnum = model.getSpaceByName("Level 0 Ceiling Plenum") - expect(plnum).to_not be_empty - plnum = plnum.get - expect(TBD.unconditioned?(plnum)).to be false - - key = "space_conditioning_category" - val = "Unconditioned" - expect(plnum.additionalProperties.hasFeature(key)).to be false - expect(plnum.additionalProperties.setFeature(key, val)).to be true - expect(TBD.plenum?(plnum)).to be true - expect(TBD.unconditioned?(plnum)).to be true - expect(TBD.setpoints(plnum)[:heating]).to be_nil - expect(TBD.setpoints(plnum)[:cooling]).to be_nil - expect(TBD.status).to be_zero - - # Generate a new SurfacePropertyOtherSideCoefficients object. - other = OpenStudio::Model::SurfacePropertyOtherSideCoefficients.new(model) - other.setName("other_side_coefficients") - expect(other.setZoneAirTemperatureCoefficient(1)).to be true + expect(surfaces[b]).to have_key(:heatloss) + h = surfaces[b][:heatloss] + expect(h).to be_within(TOL).of(20.11) if id.include?("north") + expect(h).to be_within(TOL).of(20.22) if id.include?("south") + expect(h).to be_within(TOL).of(13.42) if id.include?( "west") + expect(h).to be_within(TOL).of(13.42) if id.include?( "east") - # Reset outside boundary conditions for "open area wall 5" (and plenum wall - # above) by assigning an "OtherSideCoefficients" object (no longer relying - # on "Adiabatic" string). - id1 = "Openarea 1 Wall 5" - s1 = model.getSurfaceByName(id1) - expect(s1).to_not be_empty - s1 = s1.get - expect(s1.setSurfacePropertyOtherSideCoefficients(other)).to be true - expect(s1.outsideBoundaryCondition).to eq("OtherSideCoefficients") + # Derated constructions? + s = model.getSurfaceByName(id) + expect(s).to_not be_empty + s = s.get + expect(s.nameString).to eq(id) + expect(s.surfaceType).to eq("Floor") - id2 = "Level0 Open area 1 Ceiling Plenum AbvClgPlnmWall 5" - s2 = model.getSurfaceByName(id2) - expect(s2).to_not be_empty - s2 = s2.get - expect(s2.setSurfacePropertyOtherSideCoefficients(other)).to be true - expect(s2.outsideBoundaryCondition).to eq("OtherSideCoefficients") + # In the small office OSM, attic floor constructions are not set by + # the attic default construction set. They are instead set for the + # adjacent ceilings below (building default construction set). So + # attic floor surfaces automatically inherit derated constructions. + expect(s.isConstructionDefaulted).to be true + c = s.construction.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(2) + expect(c.layers[0].nameString).to eq("5/8 in. Gypsum Board") + expect(c.layers[1].nameString).to include("m tbd") - argh = {} - argh[:option ] = "compliant" - argh[:io_path ] = File.join(__dir__, "../json/tbd_seb_n8.json") - argh[:schema_path] = File.join(__dir__, "../tbd.schema.json") + # Comparing derating ratios of constructions. + expect(c.layers[1].to_MasslessOpaqueMaterial).to_not be_empty + m = c.layers[1].to_MasslessOpaqueMaterial.get + expect(surface[:filmRSI].round(4)).to eq(0.2665) - 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.status).to be_zero - expect(TBD.logs).to be_empty - expect(surfaces).to be_a(Hash) - expect(surfaces.size).to eq(56) - expect(io).to be_a(Hash) - expect(io).to have_key(:edges) - expect(io[:edges].size).to eq(79) + # Before derating. + # "5/8 in. Gypsum Board" : RSi = 0.0994 m2.K/W + # "Typical Insulation R-35.4 1" : RSi = 6.2348 m2.K/W + # surface air film resistances : RSi = 0.2665 m2.K/W + # ----------------------------- ------------------- + # RSi = 6.6007 m2.K/W + initial_R = surface[:filmRSI] + initial_R += 0.0994 + initial_R += 6.2348 + expect(initial_R.round(3)).to eq(6.601) - ids = { a: "Entryway Wall 4", - b: "Entryway Wall 5", - c: "Entryway Wall 6", - d: "Entry way DroppedCeiling", - e: "Utility1 Wall 1", - f: "Utility1 Wall 5", - g: "Utility 1 DroppedCeiling", - h: "Smalloffice 1 Wall 1", - i: "Smalloffice 1 Wall 2", - j: "Smalloffice 1 Wall 6", - k: "Small office 1 DroppedCeiling", - l: "Openarea 1 Wall 3", - m: "Openarea 1 Wall 4", - o: "Openarea 1 Wall 6", - p: "Openarea 1 Wall 7", - q: "Open area 1 DroppedCeiling" - }.freeze # removed n: "Openarea 1 Wall 5" + # After derating. + derated_R = surface[:filmRSI] + derated_R += 0.0994 + derated_R += m.thermalResistance - surfaces.each do |id, surface| - expect(ids).to have_value(id) if surface.key?(:edges) - expect(ids).to_not have_value(id) unless surface.key?(:edges) + ratio = -(initial_R - derated_R) * 100 / initial_R + expect(ratio).to be_within(1).of(surfaces[b][:ratio]) end surfaces.each do |id, surface| next unless surface.key?(:edges) - expect(ids).to have_value(id) - expect(surface).to have_key(:ratio) expect(surface).to have_key(:heatloss) + + if id == "Core_ZN_ceiling" + expect(surface[:heatloss]).to be_within(0.001).of(0) + expect(surface).to_not have_key(:ratio) + expect(surface).to have_key(:u) + expect(surface[:u]).to be_within(0.001).of(0.152) + next + end + + expect(surface).to have_key(:ratio) h = surface[:heatloss] s = model.getSurfaceByName(id) expect(s).to_not be_empty @@ -4795,48 +4281,56 @@ expect(s.nameString).to eq(id) expect(s.isConstructionDefaulted).to be false expect(s.construction.get.nameString).to include(" tbd") + next unless s.surfaceType == "Wall" - expect(h).to be_within(TOL).of( 3.62) if id == ids[:a] - expect(h).to be_within(TOL).of( 6.28) if id == ids[:b] - expect(h).to be_within(TOL).of( 2.62) if id == ids[:c] - expect(h).to be_within(TOL).of( 0.17) if id == ids[:d] - expect(h).to be_within(TOL).of( 7.13) if id == ids[:e] - expect(h).to be_within(TOL).of( 7.09) if id == ids[:f] - expect(h).to be_within(TOL).of( 0.20) if id == ids[:g] - expect(h).to be_within(TOL).of( 7.94) if id == ids[:h] - expect(h).to be_within(TOL).of( 5.17) if id == ids[:i] - expect(h).to be_within(TOL).of( 5.01) if id == ids[:j] - expect(h).to be_within(TOL).of( 0.22) if id == ids[:k] - expect(h).to be_within(TOL).of( 2.47) if id == ids[:l] - expect(h).to be_within(TOL).of( 4.03) if id == ids[:m] # 3.11 - expect(h).to be_within(TOL).of( 4.43) if id == ids[:n] - expect(h).to be_within(TOL).of( 4.27) if id == ids[:o] # 3.35 - expect(h).to be_within(TOL).of( 2.12) if id == ids[:p] - expect(h).to be_within(TOL).of( 2.16) if id == ids[:q] # 0.31 + expect(h).to be_within(TOL).of(51.17) if id.include?("_1_") # South + expect(h).to be_within(TOL).of(33.08) if id.include?("_2_") # East + expect(h).to be_within(TOL).of(48.32) if id.include?("_3_") # North + expect(h).to be_within(TOL).of(33.08) if id.include?("_4_") # West - # The 2x side walls linked to the new party wall "Openarea 1 Wall 5": - # - "Openarea 1 Wall 4", ids[m] - # - "Openarea 1 Wall 6", ids[o] - # ... have 1x half-corner replaced by 100% of a party wall edge, hence - # the increase in extra heat loss. - # - # The "Open area 1 DroppedCeiling", ids[q], has almost a 7x increase in - # extra heat loss. It used to take ~7.6% of the parapet PSI it shared with - # "Wall 5". As the latter is no longer a deratable surface (i.e., a party - # wall), the dropped ceiling hence takes on 100% of the party wall edge - # it still shares with "Wall 5". c = s.construction expect(c).to_not be_empty c = c.get.to_LayeredConstruction expect(c).to_not be_empty c = c.get - i = 0 - i = 2 if s.outsideBoundaryCondition.downcase == "outdoors" - expect(c.layers[i].nameString).to include("m tbd") + expect(c.layers.size).to eq(4) + expect(c.layers[2].nameString).to include("m tbd") + next unless id.include?("_1_") # South + + l_fen = 0 + l_head = 0 + l_sill = 0 + l_jamb = 0 + l_grade = 0 + l_parapet = 0 + l_corner = 0 + + surface[:edges].values.each do |edge| + l_fen += edge[:length] if edge[:type] == :fenestration + l_head += edge[:length] if edge[:type] == :head + l_sill += edge[:length] if edge[:type] == :sill + l_jamb += edge[:length] if edge[:type] == :jamb + l_grade += edge[:length] if edge[:type] == :grade + l_grade += edge[:length] if edge[:type] == :gradeconcave + l_grade += edge[:length] if edge[:type] == :gradeconvex + l_parapet += edge[:length] if edge[:type] == :parapet + l_parapet += edge[:length] if edge[:type] == :parapetconcave + l_parapet += edge[:length] if edge[:type] == :parapetconvex + l_corner += edge[:length] if edge[:type] == :cornerconcave + l_corner += edge[:length] if edge[:type] == :cornerconvex + end + + expect(l_fen ).to be_within(TOL).of( 0.00) + expect(l_head ).to be_within(TOL).of(12.81) + expect(l_sill ).to be_within(TOL).of(10.98) + expect(l_jamb ).to be_within(TOL).of(22.56) + expect(l_grade ).to be_within(TOL).of(27.69) + expect(l_parapet).to be_within(TOL).of(27.69) + expect(l_corner ).to be_within(TOL).of( 6.10) end end - it "can factor in unenclosed space such as attics" do + it "can process DOE prototype smalloffice.osm (hardset)" do translator = OpenStudio::OSVersion::VersionTranslator.new TBD.clean! @@ -4846,16 +4340,43 @@ expect(model).to_not be_empty model = model.get - argh = {} - argh[:option ] = "compliant" # superseded by :building PSI set on file - argh[:io_path ] = File.join(__dir__, "../json/tbd_smalloffice.json") - argh[:schema_path] = File.join(__dir__, "../tbd.schema.json") + # In the preceding test, attic floor surfaces inherit constructions from + # adjacent office ceiling surfaces below. In this variant, attic floors + # adjacent to NSEW perimeter office ceilings have hardset constructions + # assigned to them (inverted). Results should remain the same as above. + model.getSurfaces.each do |s| + expect(s.space).to_not be_empty + next unless s.space.get.nameString == "Attic" + next unless s.nameString.include?("_perimeter") - 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 ] + expect(s.surfaceType).to eq("Floor") + expect(s.isConstructionDefaulted).to be true + c = s.construction.get.to_LayeredConstruction + expect(c).to_not be_empty + c = c.get + expect(c.layers.size).to eq(2) + # layer[0]: "5/8 in. Gypsum Board" + # layer[1]: "Typical Insulation R-35.4 1" + + construction = c.clone(model).to_LayeredConstruction.get + expect(construction.handle.to_s).to_not be_empty + expect(construction.nameString).to_not be_empty + + str = "Typical Wood Joist Attic Floor R-37.04 2" + expect(construction.nameString).to eq(str) + construction.setName("#{s.nameString} floor") + expect(construction.layers.size).to eq(2) + expect(s.setConstruction(construction)).to be true + expect(s.isConstructionDefaulted).to be false + end + + argh = { option: "poor (BETBG)" } + + 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.status).to be_zero expect(TBD.logs).to be_empty @@ -4865,68 +4386,180 @@ expect(io).to have_key(:edges) expect(io[:edges].size).to eq(105) - # Check derating of attic floor (5x surfaces). - model.getSpaces.each do |space| - next unless space.nameString == "Attic" + # Testing attic surfaces. + surfaces.each do |id, surface| + expect(surface).to have_key(:space) + next unless surface[:space].nameString == "Attic" - expect(space.thermalZone).to_not be_empty - zone = space.thermalZone.get - expect(zone.isPlenum).to be false - expect(zone.canBePlenum).to be true - expect(TBD.plenum?(space)).to be false + # Attic is an UNENCLOSED zone - outdoor-facing surfaces are not derated. + expect(surface).to have_key(:filmRSI) + expect(surface).to have_key(:conditioned) + expect(surface[:conditioned]).to be false + expect(surface).to_not have_key(:heatloss) + expect(surface).to_not have_key(:ratio) - space.surfaces.each do |s| - id = s.nameString - expect(surfaces).to have_key(id) - expect(surfaces[id]).to have_key(:space) - next unless surfaces[id][:space].nameString == "Attic" + expect(surface).to have_key(:boundary) + b = surface[:boundary] + next if b == "outdoors" - expect(surfaces[id][:conditioned]).to be false - next if surfaces[id][:boundary] == "outdoors" + expect(surfaces).to have_key(b) + expect(surfaces[b]).to have_key(:conditioned) + expect(surfaces[b][:conditioned]).to be true + next if id == "Attic_floor_core" - expect(s.adjacentSurface).to_not be_empty - adjacent = s.adjacentSurface.get.nameString - expect(surfaces).to have_key(adjacent) - expect(surfaces[id][:boundary]).to eq(adjacent) - expect(surfaces[adjacent][:conditioned]).to be true - end - end + expect(surfaces[b]).to have_key(:ratio) + expect(surfaces[b]).to have_key(:heatloss) + h = surfaces[b][:heatloss] - # Check derating of ceilings (below attic). - surfaces.each do |id, surface| - next unless surface.key?(:ratio) - next if surface[:boundary] == "outdoors" + # Derated constructions? + s = model.getSurfaceByName(id) + expect(s).to_not be_empty + s = s.get + expect(s.surfaceType).to eq("Floor") + expect(s.isConstructionDefaulted).to be false + c = s.construction.get.to_LayeredConstruction + expect(c).to_not be_empty + c = c.get + next unless c.nameString.include?("Attic_floor_perimeter_south") - expect(surface).to have_key(:heatloss) - expect(surface[:heatloss].abs).to be > 0 - expect(id).to include("Perimeter_ZN_") - expect(id).to include("_ceiling") + expect(c.nameString).to include("c tbd") + expect(c.layers.size).to eq(2) + expect(c.layers[0].nameString).to eq("5/8 in. Gypsum Board") + expect(c.layers[1].nameString).to include("m tbd") + expect(c.layers[1].to_MasslessOpaqueMaterial).to_not be_empty + m = c.layers[1].to_MasslessOpaqueMaterial.get + expect(surface[:filmRSI].round(4)).to eq(0.2665) + + # Before derating. + # "5/8 in. Gypsum Board" : RSi = 0.0994 m2.K/W + # "Typical Insulation R-35.4 1" : RSi = 6.2348 m2.K/W + # surface air film resistances : RSi = 0.2665 m2.K/W + # ----------------------------- ------------------- + # RSi = 6.6007 m2.K/W + initial_R = surface[:filmRSI] + initial_R += 0.0994 + initial_R += 6.2348 + expect(initial_R.round(3)).to eq(6.601) + + # After derating. + derated_R = surface[:filmRSI] + derated_R += 0.0994 + derated_R += m.thermalResistance + expect(derated_R.round(3)).to eq(3.319) + + ratio = -(initial_R - derated_R) * 100 / initial_R + expect(ratio.round(2)).to eq(-49.72) + expect(ratio).to be_within(1).of(surfaces[b][:ratio]) end - # Check derating of outdoor-facing walls. surfaces.each do |id, surface| - next unless surface.key?(:ratio) - next unless surface[:boundary] == "outdoors" + next unless surface.key?(:edges) expect(surface).to have_key(:heatloss) - expect(surface[:heatloss].abs).to be > 0 + + if id == "Core_ZN_ceiling" + expect(surface[:heatloss]).to be_within(0.001).of(0) + expect(surface).to_not have_key(:ratio) + expect(surface).to have_key(:u) + expect(surface[:u]).to be_within(0.001).of(0.152) + next + end + + expect(surface).to have_key(:ratio) + h = surface[:heatloss] + s = model.getSurfaceByName(id) + expect(s).to_not be_empty + s = s.get + expect(s.nameString).to eq(id) + expect(s.isConstructionDefaulted).to be false + expect(s.construction.get.nameString).to include(" tbd") + next unless s.surfaceType == "Wall" + + expect(h).to be_within(TOL).of(51.17) if id.include?("_1_") # South + expect(h).to be_within(TOL).of(33.08) if id.include?("_2_") # East + expect(h).to be_within(TOL).of(48.32) if id.include?("_3_") # North + expect(h).to be_within(TOL).of(33.08) if id.include?("_4_") # West + + 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.layers.size).to eq(4) + expect(c.layers[2].nameString).to include("m tbd") + next unless id.include?("_1_") # South + + l_fen = 0 + l_head = 0 + l_sill = 0 + l_jamb = 0 + l_grade = 0 + l_parapet = 0 + l_corner = 0 + + surface[:edges].values.each do |edge| + l_fen += edge[:length] if edge[:type] == :fenestration + l_head += edge[:length] if edge[:type] == :head + l_sill += edge[:length] if edge[:type] == :sill + l_jamb += edge[:length] if edge[:type] == :jamb + l_grade += edge[:length] if edge[:type] == :grade + l_grade += edge[:length] if edge[:type] == :gradeconcave + l_grade += edge[:length] if edge[:type] == :gradeconvex + l_parapet += edge[:length] if edge[:type] == :parapet + l_parapet += edge[:length] if edge[:type] == :parapetconcave + l_parapet += edge[:length] if edge[:type] == :parapetconvex + l_corner += edge[:length] if edge[:type] == :cornerconcave + l_corner += edge[:length] if edge[:type] == :cornerconvex + end + + expect(l_fen ).to be_within(TOL).of( 0.00) + expect(l_head ).to be_within(TOL).of(12.81) + expect(l_sill ).to be_within(TOL).of(10.98) + expect(l_jamb ).to be_within(TOL).of(22.56) + expect(l_grade ).to be_within(TOL).of(27.69) + expect(l_parapet).to be_within(TOL).of(27.69) + expect(l_corner ).to be_within(TOL).of( 6.10) end end - it "can factor in heads, sills and jambs" do + it "can process DOE Prototype warehouse.osm" do translator = OpenStudio::OSVersion::VersionTranslator.new TBD.clean! - file = File.join(__dir__, "files/osms/in/warehouse.osm") - path = OpenStudio::Path.new(file) + 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 - argh = {} - argh[:option ] = "compliant" # superseded by :building PSI set on file - argh[:io_path ] = File.join(__dir__, "../json/tbd_warehouse7.json") - argh[:schema_path] = File.join(__dir__, "../tbd.schema.json") + model.getSurfaces.each do |s| + next unless s.outsideBoundaryCondition.downcase == "outdoors" + + expect(s.space).to_not be_empty + expect(s.isConstructionDefaulted).to be true + c = s.construction + expect(c).to_not be_empty + c = c.get.to_LayeredConstruction + expect(c).to_not be_empty + c = c.get + + id = c.nameString + name = s.nameString + expect(c.layers[1].to_MasslessOpaqueMaterial).to_not be_empty + + m = c.layers[1].to_MasslessOpaqueMaterial.get + r = m.thermalResistance + + if name.include?("Bulk") + expect(r).to be_within(TOL).of(1.33) if id.include?("Wall") + expect(r).to be_within(TOL).of(1.68) if id.include?("Roof") + else + expect(r).to be_within(TOL).of(1.87) if id.include?("Wall") + expect(r).to be_within(TOL).of(3.06) if id.include?("Roof") + end + end + + argh = { option: "poor (BETBG)" } json = TBD.process(model, argh) expect(json).to be_a(Hash) @@ -4942,326 +4575,306 @@ expect(io).to have_key(:edges) expect(io[:edges].size).to eq(300) - n_transitions = 0 - n_parapets = 0 - n_fen_edges = 0 - n_heads = 0 - n_sills = 0 - n_jambs = 0 - n_skylightheads = 0 - n_skylightsills = 0 - n_skylightjambs = 0 - - types = { - t1: :transition, - t2: :parapetconvex, - t3: :fenestration, - t4: :head, - t5: :sill, - t6: :jamb, - t7: :skylighthead, - t8: :skylightsill, - t9: :skylightjamb - }.freeze + ids = { a: "Office Front Wall", + b: "Office Left Wall", + c: "Fine Storage Roof", + d: "Fine Storage Office Front Wall", + e: "Fine Storage Office Left Wall", + f: "Fine Storage Front Wall", + g: "Fine Storage Left Wall", + h: "Fine Storage Right Wall", + i: "Bulk Storage Roof", + j: "Bulk Storage Rear Wall", + k: "Bulk Storage Left Wall", + l: "Bulk Storage Right Wall" + }.freeze + # Testing. surfaces.each do |id, surface| - next unless surface[:boundary] == "outdoors" - next unless surface.key?(:ratio) + expect(ids).to have_value(id) if surface.key?(:edges) + expect(ids).to_not have_value(id) unless surface.key?(:edges) + end - expect(surface).to have_key(:heatloss) - expect(surface[:heatloss].abs).to be > 0 - next unless id == "Bulk Storage Roof" + surfaces.each do |id, surface| + next unless surface.key?(:edges) - expect(surfaces[id]).to have_key(:edges) - expect(surfaces[id][:edges].size).to eq(132) + expect(ids).to have_value(id) + expect(surface).to have_key(:heatloss) + expect(surface).to have_key(:ratio) + h = surface[:heatloss] + s = model.getSurfaceByName(id) + expect(s).to_not be_empty + s = s.get + expect(s.nameString).to eq(id) + expect(s.isConstructionDefaulted).to be false + expect(s.construction.get.nameString).to include(" tbd") + expect(h).to be_within(TOL).of( 50.20) if id == ids[:a] + expect(h).to be_within(TOL).of( 24.06) if id == ids[:b] + expect(h).to be_within(TOL).of( 87.16) if id == ids[:c] + expect(h).to be_within(TOL).of( 22.61) if id == ids[:d] + expect(h).to be_within(TOL).of( 9.15) if id == ids[:e] + expect(h).to be_within(TOL).of( 26.47) if id == ids[:f] + expect(h).to be_within(TOL).of( 27.19) if id == ids[:g] + expect(h).to be_within(TOL).of( 41.36) if id == ids[:h] + expect(h).to be_within(TOL).of(161.02) if id == ids[:i] + expect(h).to be_within(TOL).of( 62.28) if id == ids[:j] + expect(h).to be_within(TOL).of(117.87) if id == ids[:k] + expect(h).to be_within(TOL).of( 95.77) if id == ids[:l] - surfaces[id][:edges].values.each do |edge| - expect(edge).to have_key(:type) - t = edge[:type] - expect(types.values).to include(t) + 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.layers[1].nameString).to include("m tbd") + end - n_transitions += 1 if edge[:type] == types[:t1] - n_parapets += 1 if edge[:type] == types[:t2] - n_fen_edges += 1 if edge[:type] == types[:t3] - n_heads += 1 if edge[:type] == types[:t4] - n_sills += 1 if edge[:type] == types[:t5] - n_jambs += 1 if edge[:type] == types[:t6] - n_skylightheads += 1 if edge[:type] == types[:t7] - n_skylightsills += 1 if edge[:type] == types[:t8] - n_skylightjambs += 1 if edge[:type] == types[:t9] + surfaces.each do |id, surface| + if surface.key?(:ratio) + # ratio = format "%3.1f", surface[:ratio] + # name = id.rjust(15, " ") + # puts "#{name} RSi derated by #{ratio}%" + expect(surface[:ratio]).to be_within(0.2).of(-53.0) if id == ids[:b] + expect(surface[:ratio]).to be_within(0.2).of(-15.6) if id == ids[:c] + expect(surface[:ratio]).to be_within(0.2).of(- 7.3) if id == ids[:i] + else + expect(surface[:boundary]).to_not eq("outdoors") end end - - expect(n_transitions ).to eq( 1) - expect(n_parapets ).to eq( 3) - expect(n_fen_edges ).to eq( 0) - expect(n_heads ).to eq( 0) - expect(n_sills ).to eq( 0) - expect(n_jambs ).to eq( 0) - expect(n_skylightheads).to eq( 0) - expect(n_skylightsills).to eq( 0) - expect(n_skylightjambs).to eq(128) end - it "has a PSI class" do + it "can process DOE Prototype warehouse.osm + JSON I/O" do + translator = OpenStudio::OSVersion::VersionTranslator.new TBD.clean! - psi = TBD::PSI.new - expect(psi.set).to have_key("poor (BETBG)") - expect(psi.complete?("poor (BETBG)")).to be true - expect(TBD.status).to be_zero - expect(TBD.logs).to be_empty + 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 - expect(psi.set).to_not have_key("new set") - expect(psi.complete?("new set")).to be false - expect(TBD.error?).to be true - expect(TBD.logs.size).to eq(1) - TBD.clean! + # Run the measure with a basic TBD JSON input file, e.g. + # - a custom PSI set, e.g. "compliant" set + # - (4x) custom edges, e.g. "bad" :fenestration perimeters between + # - "Office Left Wall Window1" & "Office Left Wall" + # + # The TBD JSON input file should hold the following: + # + # "edges": [ + # { + # "psi": "bad", + # "type": "fenestration", + # "surfaces": [ + # "Office Left Wall Window1", + # "Office Left Wall" + # ] + # } + # ], - new_set = { - id: "new set", - rimjoist: 0.000, - parapet: 0.000, - fenestration: 0.000, - cornerconcave: 0.000, - cornerconvex: 0.000, - balcony: 0.000, - party: 0.000, - grade: 0.000 - } + # Despite defining the PSI set as having no thermal bridges, the "compliant" + # PSI set on file will be considered as the building-wide default set. + argh = {} + argh[:option ] = "(non thermal bridging)" + argh[:io_path ] = File.join(__dir__, "../json/tbd_warehouse.json") + argh[:schema_path] = File.join(__dir__, "../tbd.schema.json") - expect(psi.append(new_set)).to be true - expect(psi.set).to have_key("new set") - expect(psi.complete?("new set")).to be true + 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.status).to be_zero expect(TBD.logs).to be_empty + expect(surfaces).to be_a(Hash) + expect(surfaces.size).to eq(23) + expect(io).to be_a(Hash) + expect(io).to have_key(:edges) + expect(io[:edges].size).to eq(300) - expect(psi.set["new set"][:grade].to_i).to be_zero - new_set[:grade] = 1.0 - expect(psi.append(new_set)).to be false # does not override existing value - expect(TBD.error?).to be true - expect(TBD.logs.size).to eq(1) - expect(psi.set["new set"][:grade].to_i).to be_zero - expect(psi.set).to_not have_key("incomplete set") - expect(psi.complete?("incomplete set")).to be false + ids = { a: "Office Front Wall", + b: "Office Left Wall", + c: "Fine Storage Roof", + d: "Fine Storage Office Front Wall", + e: "Fine Storage Office Left Wall", + f: "Fine Storage Front Wall", + g: "Fine Storage Left Wall", + h: "Fine Storage Right Wall", + i: "Bulk Storage Roof", + j: "Bulk Storage Rear Wall", + k: "Bulk Storage Left Wall", + l: "Bulk Storage Right Wall" + }.freeze - incomplete_set = { - id: "incomplete set", - grade: 0.000 - }.freeze + surfaces.each do |id, surface| + expect(ids).to have_value(id) if surface.key?(:edges) + expect(ids).to_not have_value(id) unless surface.key?(:edges) + end - expect(psi.append(incomplete_set)).to be true - expect(psi.set).to have_key("incomplete set") - expect(psi.complete?("incomplete set")).to be false - expect(psi.set).to_not have_key("all sills") + surfaces.each do |id, surface| + next unless surface.key?(:edges) - all_sills = { - id: "all sills", - fenestration: 0.391, - head: 0.381, - headconcave: 0.382, - headconvex: 0.383, - sill: 0.371, - sillconcave: 0.372, - sillconvex: 0.373, - jamb: 0.361, - jambconcave: 0.362, - jambconvex: 0.363, - rimjoist: 0.001, - parapet: 0.002, - corner: 0.003, - balcony: 0.004, - party: 0.005, - grade: 0.006 - }.freeze + expect(ids).to have_value(id) + expect(surface).to have_key(:ratio) + expect(surface).to have_key(:heatloss) + h = surface[:heatloss] + s = model.getSurfaceByName(id) + expect(s).to_not be_empty + s = s.get + expect(s.nameString).to eq(id) + expect(s.isConstructionDefaulted).to be false + expect(s.construction.get.nameString).to include(" tbd") + expect(h).to be_within(TOL).of( 25.90) if id == ids[:a] + expect(h).to be_within(TOL).of( 17.41) if id == ids[:b] # 13.38 compliant + expect(h).to be_within(TOL).of( 45.44) if id == ids[:c] + expect(h).to be_within(TOL).of( 8.04) if id == ids[:d] + expect(h).to be_within(TOL).of( 3.46) if id == ids[:e] + expect(h).to be_within(TOL).of( 13.27) if id == ids[:f] + expect(h).to be_within(TOL).of( 14.04) if id == ids[:g] + expect(h).to be_within(TOL).of( 21.20) if id == ids[:h] + expect(h).to be_within(TOL).of( 88.34) if id == ids[:i] + expect(h).to be_within(TOL).of( 30.98) if id == ids[:j] + expect(h).to be_within(TOL).of( 64.44) if id == ids[:k] + expect(h).to be_within(TOL).of( 48.97) if id == ids[:l] - expect(psi.append(all_sills)).to be true - expect(psi.set).to have_key("all sills") - expect(psi.complete?("all sills")).to be true + 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.layers[1].nameString).to include("m tbd") + end - shorts = psi.shorthands("all sills") - expect(shorts[:has]).to_not be_empty - expect(shorts[:val]).to_not be_empty + surfaces.each do |id, surface| + if surface.key?(:ratio) + # ratio = format "%3.1f", surface[:ratio] + # name = id.rjust(15, " ") + # puts "#{name} RSi derated by #{ratio}%" + expect(surface[:ratio]).to be_within(0.2).of(-46.0) if id == ids[:b] + else + expect(surface[:boundary]).to_not eq("outdoors") + end + end - holds = shorts[:has] - vals = shorts[:val] - expect(holds[:fenestration]).to be true - expect(vals[:sill ]).to be_within(0.001).of(0.371) - expect(vals[:sillconcave]).to be_within(0.001).of(0.372) - expect(vals[:sillconvex ]).to be_within(0.001).of(0.373) - expect(psi.set).to_not have_key("partial sills") + # Now mimic the export functionality of the measure. + out = JSON.pretty_generate(io) + outP = File.join(__dir__, "../json/tbd_warehouse.out.json") + File.open(outP, "w") { |outP| outP.puts out } - partial_sills = { - id: "partial sills", - fenestration: 0.391, - head: 0.381, - headconcave: 0.382, - headconvex: 0.383, - sill: 0.371, - sillconcave: 0.372, - # sillconvex: 0.373, # dropping the convex variant - jamb: 0.361, - jambconcave: 0.362, - jambconvex: 0.363, - rimjoist: 0.001, - parapet: 0.002, - corner: 0.003, - balcony: 0.004, - party: 0.005, - grade: 0.006 - }.freeze - - expect(psi.append(partial_sills)).to be true - expect(psi.set).to have_key("partial sills") - expect(psi.complete?("partial sills")).to be true # can be a building set - shorts = psi.shorthands("partial sills") - expect(shorts[:has]).to_not be_empty - expect(shorts[:val]).to_not be_empty - - holds = shorts[:has] - vals = shorts[:val] - expect(holds[:sillconvex]).to be false # absent from PSI set - expect(vals[:sill ]).to be_within(0.001).of(0.371) - expect(vals[:sillconcave]).to be_within(0.001).of(0.372) - expect(vals[:sillconvex ]).to be_within(0.001).of(0.371) # inherits :sill - expect(psi.set).to_not have_key("no sills") - - no_sills = { - id: "no sills", - fenestration: 0.391, - head: 0.381, - headconcave: 0.382, - headconvex: 0.383, - # sill: 0.371, # dropping the concave variant - # sillconcave: 0.372, # dropping the concave variant - # sillconvex: 0.373, # dropping the convex variant - jamb: 0.361, - jambconcave: 0.362, - jambconvex: 0.363, - rimjoist: 0.001, - parapet: 0.002, - corner: 0.003, - balcony: 0.004, - party: 0.005, - grade: 0.006 - }.freeze - - expect(psi.append(no_sills)).to be true - expect(psi.set).to have_key("no sills") - expect(psi.complete?("no sills")).to be true # can be a building set - shorts = psi.shorthands("no sills") - expect(shorts[:has]).to_not be_empty - expect(shorts[:val]).to_not be_empty - - holds = shorts[:has] - vals = shorts[:val] - expect(holds[:sill ]).to be false # absent from PSI set - expect(holds[:sillconcave]).to be false # absent from PSI set - expect(holds[:sillconvex ]).to be false # absent from PSI set - expect(vals[:sill ]).to be_within(0.001).of(0.391) - expect(vals[:sillconcave ]).to be_within(0.001).of(0.391) - expect(vals[:sillconvex ]).to be_within(0.001).of(0.391) # :fenestration - end - - it "can factor-in Frame & Divider (F&D) objects" do - translator = OpenStudio::OSVersion::VersionTranslator.new - version = OpenStudio.openStudioVersion.split(".").join.to_i - TBD.clean! - - model = OpenStudio::Model::Model.new - vec = OpenStudio::Point3dVector.new - vec << OpenStudio::Point3d.new( 2.00, 0.00, 3.00) - vec << OpenStudio::Point3d.new( 2.00, 0.00, 1.00) - vec << OpenStudio::Point3d.new( 4.00, 0.00, 1.00) - vec << OpenStudio::Point3d.new( 4.00, 0.00, 3.00) - sub = OpenStudio::Model::SubSurface.new(vec, model) - - # Aide-mémoire: attributes/objects subsurfaces are allowed to have/be. - OpenStudio::Model::SubSurface.validSubSurfaceTypeValues.each do |type| - expect(sub.setSubSurfaceType(type)).to be true - # FixedWindow - # OperableWindow - # Door - # GlassDoor - # OverheadDoor - # Skylight - # TubularDaylightDome - # TubularDaylightDiffuser - case type - when "FixedWindow" - expect(sub.allowWindowPropertyFrameAndDivider ).to be true - next if version < 330 - - expect(sub.allowDaylightingDeviceTubularDiffuser).to be false - expect(sub.allowDaylightingDeviceTubularDome ).to be false - when "OperableWindow" - expect(sub.allowWindowPropertyFrameAndDivider ).to be true - next if version < 330 - - expect(sub.allowDaylightingDeviceTubularDiffuser).to be false - expect(sub.allowDaylightingDeviceTubularDome ).to be false - when "Door" - expect(sub.allowWindowPropertyFrameAndDivider ).to be false - next if version < 330 - - expect(sub.allowDaylightingDeviceTubularDiffuser).to be false - expect(sub.allowDaylightingDeviceTubularDome ).to be false - when "GlassDoor" - expect(sub.allowWindowPropertyFrameAndDivider ).to be true - next if version < 330 + # 2. Re-use the exported file as input for another warehouse. + model2 = translator.loadModel(path) + expect(model2).to_not be_empty + model2 = model2.get - expect(sub.allowDaylightingDeviceTubularDiffuser).to be false - expect(sub.allowDaylightingDeviceTubularDome ).to be false - when "OverheadDoor" - expect(sub.allowWindowPropertyFrameAndDivider ).to be false - next if version < 330 + argh[:io_path] = File.join(__dir__, "../json/tbd_warehouse.out.json") - expect(sub.allowDaylightingDeviceTubularDiffuser).to be false - expect(sub.allowDaylightingDeviceTubularDome ).to be false - when "Skylight" - if version < 321 - expect(sub.allowWindowPropertyFrameAndDivider ).to be false - else - expect(sub.allowWindowPropertyFrameAndDivider ).to be true - end + json2 = TBD.process(model2, argh) + expect(json2).to be_a(Hash) + expect(json2).to have_key(:io) + expect(json2).to have_key(:surfaces) + io2 = json2[:io ] + surfaces = json2[:surfaces] + expect(TBD.status).to be_zero + expect(TBD.logs).to be_empty + expect(surfaces).to be_a(Hash) + expect(surfaces.size).to eq(23) + expect(io).to be_a(Hash) + expect(io).to have_key(:edges) + expect(io[:edges].size).to eq(300) - next if version < 330 + # Testing (again). + surfaces.each do |id, surface| + next unless surface.key?(:edges) - expect(sub.allowDaylightingDeviceTubularDiffuser).to be false - expect(sub.allowDaylightingDeviceTubularDome ).to be false - when "TubularDaylightDome" - expect(sub.allowWindowPropertyFrameAndDivider ).to be false - next if version < 330 + expect(surface).to have_key(:ratio) + expect(surface).to have_key(:heatloss) + h = surface[:heatloss] + s = model.getSurfaceByName(id) + expect(s).to_not be_empty + s = s.get + expect(s.nameString).to eq(id) + expect(s.isConstructionDefaulted).to be false + expect(s.construction.get.nameString).to include(" tbd") + expect(h).to be_within(TOL).of( 25.90) if id == ids[:a] + expect(h).to be_within(TOL).of( 17.41) if id == ids[:b] + expect(h).to be_within(TOL).of( 45.44) if id == ids[:c] + expect(h).to be_within(TOL).of( 8.04) if id == ids[:d] + expect(h).to be_within(TOL).of( 3.46) if id == ids[:e] + expect(h).to be_within(TOL).of( 13.27) if id == ids[:f] + expect(h).to be_within(TOL).of( 14.04) if id == ids[:g] + expect(h).to be_within(TOL).of( 21.20) if id == ids[:h] + expect(h).to be_within(TOL).of( 88.34) if id == ids[:i] + expect(h).to be_within(TOL).of( 30.98) if id == ids[:j] + expect(h).to be_within(TOL).of( 64.44) if id == ids[:k] + expect(h).to be_within(TOL).of( 48.97) if id == ids[:l] - expect(sub.allowDaylightingDeviceTubularDiffuser).to be false - expect(sub.allowDaylightingDeviceTubularDome ).to be true - when "TubularDaylightDiffuser" - expect(sub.allowWindowPropertyFrameAndDivider ).to be false - next if version < 330 + 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.layers[1].nameString).to include("m tbd") + end - expect(sub.allowDaylightingDeviceTubularDiffuser).to be true - expect(sub.allowDaylightingDeviceTubularDome ).to be false + surfaces.each do |id, surface| + if surface.key?(:ratio) + # ratio = format "%3.1f", surface[:ratio] + # name = id.rjust(15, " ") + # puts "#{name} RSi derated by #{ratio}%" + expect(surface[:ratio]).to be_within(0.2).of(-46.0) if id == ids[:b] else - expect(true).to be false # Unknown SubSurfaceType! + expect(surface[:boundary]).to_not eq("outdoors") end end + # Now mimic (again) the export functionality of the measure. Both output + # files should be the same. + out2 = JSON.pretty_generate(io2) + outP2 = File.join(__dir__, "../json/tbd_warehouse2.out.json") + File.open(outP2, "w") { |outP2| outP2.puts out2 } + expect(FileUtils).to be_identical(outP, outP2) + end + + it "can process DOE Prototype warehouse.osm + JSON I/O (2)" do + translator = OpenStudio::OSVersion::VersionTranslator.new + TBD.clean! + 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 - wll = "Office Front Wall" - win = "Office Front Wall Window 1" - - front = model.getSurfaceByName(wll) - expect(front).to_not be_empty - front = front.get + # Run the measure with a basic TBD JSON input file, e.g. + # - a custom PSI set, e.g. "compliant" set + # - (1x) custom edges, e.g. "bad" :fenestration perimeters between + # - "Office Left Wall Window1" & "Office Left Wall" + # - 1x? this time, with explicit 3D coordinates for shared edge. + # + # The TBD JSON input file should hold the following: + # + # "edges": [ + # { + # "psi": "bad", + # "type": "fen", + # "surfaces": [ + # "Office Left Wall Window1", + # "Office Left Wall" + # ], + # "v0x": 0.0, + # "v0y": 7.51904930207155, + # "v0z": 0.914355407629293, + # "v1x": 0.0, + # "v1y": 5.38555335093654, + # "v1z": 0.914355407629293 + # } + # ], + # Despite defining the PSI set as having no thermal bridges, the "compliant" + # PSI set on file will be considered as the building-wide default set. argh = {} - argh[:option ] = "poor (BETBG)" - argh[:io_path ] = File.join(__dir__, "../json/tbd_warehouse8.json") + argh[:option ] = "(non thermal bridging)" + argh[:io_path ] = File.join(__dir__, "../json/tbd_warehouse1.json") argh[:schema_path] = File.join(__dir__, "../tbd.schema.json") json = TBD.process(model, argh) @@ -5278,1074 +4891,1061 @@ expect(io).to have_key(:edges) expect(io[:edges].size).to eq(300) - n_transitions = 0 - n_fen_edges = 0 - n_heads = 0 - n_sills = 0 - n_jambs = 0 - n_doorheads = 0 - n_doorsills = 0 - n_doorjambs = 0 - n_grades = 0 - n_corners = 0 - n_rimjoists = 0 - fen_length = 0 + ids = { a: "Office Front Wall", + b: "Office Left Wall", + c: "Fine Storage Roof", + d: "Fine Storage Office Front Wall", + e: "Fine Storage Office Left Wall", + f: "Fine Storage Front Wall", + g: "Fine Storage Left Wall", + h: "Fine Storage Right Wall", + i: "Bulk Storage Roof", + j: "Bulk Storage Rear Wall", + k: "Bulk Storage Left Wall", + l: "Bulk Storage Right Wall" }.freeze - t1 = :transition - t2 = :fenestration - t3 = :head - t4 = :sill - t5 = :jamb - t6 = :doorhead - t7 = :doorsill - t8 = :doorjamb - t9 = :gradeconvex - t10 = :cornerconvex - t11 = :rimjoist + # Testing. + surfaces.each do |id, surface| + expect(ids).to have_value(id) if surface.key?(:edges) + expect(ids).to_not have_value(id) unless surface.key?(:edges) + end surfaces.each do |id, surface| - next unless surface[:boundary] == "outdoors" - next unless surface.key?(:ratio) + next unless surface.key?(:edges) + expect(ids).to have_value(id) + expect(surface).to have_key(:ratio) expect(surface).to have_key(:heatloss) - expect(surface[:heatloss].abs).to be > 0 - next unless id == wll - - expect(surface[:heatloss]).to be_within(0.1).of(50.2) - expect(surface).to have_key(:edges) - expect(surface[:edges].size).to eq(17) - - surface[:edges].values.each do |edge| - expect(edge).to have_key(:type) + h = surface[:heatloss] + s = model.getSurfaceByName(id) + expect(s).to_not be_empty + s = s.get + expect(s.nameString).to eq(id) + expect(s.isConstructionDefaulted).to be false + expect(s.construction.get.nameString).to include(" tbd") + expect(h).to be_within(TOL).of( 25.90) if id == ids[:a] + expect(h).to be_within(TOL).of( 14.55) if id == ids[:b] # 13.4 compliant + expect(h).to be_within(TOL).of( 45.44) if id == ids[:c] + expect(h).to be_within(TOL).of( 8.04) if id == ids[:d] + expect(h).to be_within(TOL).of( 3.46) if id == ids[:e] + expect(h).to be_within(TOL).of( 13.27) if id == ids[:f] + expect(h).to be_within(TOL).of( 14.04) if id == ids[:g] + expect(h).to be_within(TOL).of( 21.20) if id == ids[:h] + expect(h).to be_within(TOL).of( 88.34) if id == ids[:i] + expect(h).to be_within(TOL).of( 30.98) if id == ids[:j] + expect(h).to be_within(TOL).of( 64.44) if id == ids[:k] + expect(h).to be_within(TOL).of( 48.97) if id == ids[:l] - n_transitions += 1 if edge[:type] == t1 - n_fen_edges += 1 if edge[:type] == t2 - n_heads += 1 if edge[:type] == t3 - n_sills += 1 if edge[:type] == t4 - n_jambs += 1 if edge[:type] == t5 - n_doorheads += 1 if edge[:type] == t6 - n_doorsills += 1 if edge[:type] == t7 - n_doorjambs += 1 if edge[:type] == t8 - n_grades += 1 if edge[:type] == t9 - n_corners += 1 if edge[:type] == t10 - n_rimjoists += 1 if edge[:type] == t11 + 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.layers[1].nameString).to include("m tbd") + end - fen_length += edge[:length] if edge[:type] == t2 + surfaces.each do |id, surface| + if surface.key?(:ratio) + # ratio = format "%3.1f", surface[:ratio] + # name = id.rjust(15, " ") + # puts "#{name} RSi derated by #{ratio}%" + expect(surface[:ratio]).to be_within(0.2).of(-41.9) if id == ids[:b] + else + expect(surface[:boundary]).to_not eq("outdoors") end end - expect(n_transitions).to eq(1) - expect(n_fen_edges ).to eq(4) # Office Front Wall Window 1 - expect(n_heads ).to eq(1) # Window 2 - expect(n_sills ).to eq(1) # Window 2 - expect(n_jambs ).to eq(2) # Window 2 - expect(n_doorheads ).to eq(1) # door - expect(n_doorsills ).to eq(0) # grade PSI > fenestration PSI - expect(n_doorjambs ).to eq(2) # door - expect(n_grades ).to eq(3) # including door sill - expect(n_corners ).to eq(1) - expect(n_rimjoists ).to eq(1) - - # Net & gross areas, as well as fenestration perimeters, reflect cases - # without frame & divider objects. This is also what would be reported by - # SketchUp, for instance. - expect(fen_length ).to be_within(TOL).of( 10.36) # Window 1 perimeter - expect(front.netArea ).to be_within(TOL).of( 95.49) - expect(front.grossArea).to be_within(TOL).of(110.54) - - - # Open another warehouse model and add/assign a Frame & Divider object. - file = File.join(__dir__, "files/osms/in/warehouse.osm") - path = OpenStudio::Path.new(file) - model_FD = translator.loadModel(path) - expect(model_FD).to_not be_empty - model_FD = model_FD.get + # Now mimic the export functionality of the measure + out = JSON.pretty_generate(io) + outP = File.join(__dir__, "../json/tbd_warehouse1.out.json") + File.open(outP, "w") { |outP| outP.puts out } - # Adding/validating Frame & Divider object. - fd = OpenStudio::Model::WindowPropertyFrameAndDivider.new(model_FD) - width = 0.03 - expect(fd.setFrameWidth(width)).to be true # 30mm (narrow) around glazing - expect(fd.setFrameConductance(2.500)).to be true + # 2. Re-use the exported file as input for another warehouse + model2 = translator.loadModel(path) + expect(model2).to_not be_empty + model2 = model2.get - window_FD = model_FD.getSubSurfaceByName(win) - expect(window_FD).to_not be_empty - window_FD = window_FD.get + argh[:io_path] = File.join(__dir__, "../json/tbd_warehouse1.out.json") - expect(window_FD.allowWindowPropertyFrameAndDivider).to be true - expect(window_FD.setWindowPropertyFrameAndDivider(fd)).to be true - width2 = window_FD.windowPropertyFrameAndDivider.get.frameWidth - expect(width2).to be_within(TOL).of(width) + json2 = TBD.process(model2, argh) + expect(json2).to be_a(Hash) + expect(json2).to have_key(:io) + expect(json2).to have_key(:surfaces) + io2 = json2[:io ] + surfaces = json2[:surfaces] + expect(TBD.status).to be_zero + expect(TBD.logs).to be_empty + expect(surfaces).to be_a(Hash) + expect(surfaces.size).to eq(23) + expect(io).to be_a(Hash) + expect(io).to have_key(:edges) + expect(io[:edges].size).to eq(300) - front_FD = model_FD.getSurfaceByName(wll) - expect(front_FD).to_not be_empty - front_FD = front_FD.get + surfaces.each do |id, surface| + if surface.key?(:ratio) + # ratio = format "%3.1f", surface[:ratio] + # name = id.rjust(15, " ") + # puts "#{name} RSi derated by #{ratio}%" + expect(surface[:ratio]).to be_within(0.2).of(-41.9) if id == ids[:b] + else + expect(surface[:boundary]).to_not eq("outdoors") + end + end - expect(window_FD.netArea ).to be_within(TOL).of( 5.58) - expect(window_FD.grossArea).to be_within(TOL).of( 5.58) # not 5.89 (OK) - expect(front_FD.grossArea ).to be_within(TOL).of(110.54) # this is OK + # Now mimic (again) the export functionality of the measure. Both output + # files should be the same. + out2 = JSON.pretty_generate(io2) + outP2 = File.join(__dir__, "../json/tbd_warehouse3.out.json") + File.open(outP2, "w") { |outP2| outP2.puts out2 } + expect(FileUtils).to be_identical(outP, outP2) + end - unless version < 340 - # As of v3.4.0, SDK-reported WWR ratio calculations will ignore added - # frame areas if associated subsurfaces no longer 'fit' within the - # parent surface polygon, or overlap any of their siblings. For v340 and - # up, one can only rely on SDK-reported WWR to safely determine TRUE net - # area for a parent surface. - # - # For older SDK versions, TBD/OSut methods are required to do the same. - # - # https://github.com/NREL/OpenStudio/issues/4361 - # - # Here, the parent wall net area reflects the added (valid) frame areas. - # However, this net area reports erroneous values when F&D objects - # 'conflict', e.g. they don't fit in, or they overlap their siblings. - expect(window_FD.roughOpeningArea).to be_within(TOL).of( 5.89) - expect(front_FD.netArea ).to be_within(TOL).of(95.17) # great !! - expect(front_FD.windowToWallRatio).to be_within(TOL).of(0.104) # !! - else - expect(front_FD.netArea ).to be_within(TOL).of(95.49) # !95.17 - expect(front_FD.windowToWallRatio).to be_within(TOL).of(0.101) # !0.104 - end + it "can factor in spacetype-specific PSI sets (JSON input)" do + translator = OpenStudio::OSVersion::VersionTranslator.new + TBD.clean! - # If one runs an OpenStudio +v3.4 simulation with the exported file below - # ("model_FD.osm"), EnergyPlus will correctly report (e.g. eplustbl.htm) - # a building WWR (gross window-wall ratio) of 72% (vs 71% without F&D), due - # to the slight increase in area of the "Office Front Wall Window 1" (from - # 5.58 m2 to 5.89 m2). The report clearly distinguishes between the revised - # glazing area of 5.58 m2 vs a new framing area of 0.31 m2 for this window. - # Finally, the parent surface "Office Front Wall" area will also be - # correctly reported as 95.17 m2 (vs 95.49 m2). So OpenStudio is correctly - # forward translating the subsurface and linked Frame & Divider objects to - # EnergyPlus (triangular subsurfaces not tested). - # - # For prior versions to v3.4, there are discrepencies between the net area - # of the "Office Front Wall" reported by the OpenStudio API vs EnergyPlus. - # This may seem minor when looking at the numbers above, but keep in mind a - # single glazed subsurface is modified for this comparison. This difference - # could easily reach 5% to 10% for models with many windows, especially - # those with narrow aspect ratios (lots of framing). - # - # ... subsurface.netArea calculation here could be reconsidered : - # - # https://github.com/NREL/OpenStudio/blob/ - # 70a5549c439eda69d6c514a7275254f71f7e3d2b/src/model/Surface.cpp#L1446 - pth = File.join(__dir__, "files/osms/out/model_FD.osm") - model_FD.save(pth, true) + 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 argh = {} - argh[:option ] = "poor (BETBG)" - argh[:io_path ] = File.join(__dir__, "../json/tbd_warehouse8.json") + argh[:option ] = "compliant" # superseded by :building PSI set on file + argh[:io_path ] = File.join(__dir__, "../json/tbd_warehouse5.json") argh[:schema_path] = File.join(__dir__, "../tbd.schema.json") - json = TBD.process(model_FD, 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] - expect(TBD.status.zero?).to eq(true) + expect(TBD.status).to be_zero expect(TBD.logs).to be_empty expect(surfaces).to be_a(Hash) expect(surfaces.size).to eq(23) expect(io).to be_a(Hash) + expect(io).to have_key(:spacetypes) expect(io).to have_key(:edges) expect(io[:edges].size).to eq(300) - # TBD calling on workarounds. - net_area = surfaces[wll][:net ] - gross_area = surfaces[wll][:gross] - expect(net_area ).to be_within(TOL).of( 95.17) # ! API 95.49 - expect(gross_area).to be_within(TOL).of(110.54) # same - expect(surfaces[wll]).to have_key(:windows) - expect(surfaces[wll][:windows].size).to eq(2) - - surfaces[wll][:windows].each do |i, window| - expect(window).to have_key(:points) - expect(window[:points].size).to eq(4) - next unless i == win + sTyp1 = "Warehouse Office" + sTyp2 = "Warehouse Fine" - expect(window).to have_key(:gross) - expect(window[:gross]).to be_within(TOL).of(5.89) # ! API 5.58 + io[:spacetypes].each do |spacetype| + expect(spacetype).to have_key(:id) + expect(spacetype[:id]).to eq(sTyp1).or eq(sTyp2) + expect(spacetype).to have_key(:psi) end - # Adding a clerestory window, slightly above "Office Front Wall Window 1", - # to test/validate overlapping cases. Starting with a safe case. - # - # FYI, original "Office Front Wall Window 1" (without F&D widths). - # 3.66, 0, 2.44 - # 3.66, 0, 0.91 - # 7.31, 0, 0.91 - # 7.31, 0, 2.44 + surfaces.each do |id, surface| + next unless surface[:boundary] == "outdoors" + next unless surface.key?(:ratio) - cl_v = OpenStudio::Point3dVector.new - cl_v << OpenStudio::Point3d.new( 3.66, 0.00, 4.00) - cl_v << OpenStudio::Point3d.new( 3.66, 0.00, 2.47) - cl_v << OpenStudio::Point3d.new( 7.31, 0.00, 2.47) - cl_v << OpenStudio::Point3d.new( 7.31, 0.00, 4.00) - clerestory = OpenStudio::Model::SubSurface.new(cl_v, model_FD) - clerestory.setName("clerestory") - expect(clerestory.setSurface(front_FD)).to be true - expect(clerestory.setSubSurfaceType("FixedWindow")).to be true - # ... reminder: set subsurface type AFTER setting its parent surface! + expect(surface).to have_key(:heatloss) + heatloss = surface[:heatloss] + expect(heatloss.abs).to be > 0 + expect(surface).to have_key(:space) + next unless surface[:space].nameString == "Zone1 Office" - argh = { option: "poor (BETBG)" } + # All applicable thermal bridges/edges derating the office walls inherit + # the "Warehouse Office" spacetype PSI values (JSON file), except for the + # shared :rimjoist with the Fine Storage space above. The "Warehouse Fine" + # spacetype set has a higher :rimjoist PSI value of 0.5 W/K per metre, + # which overrides the "Warehouse Office" value of 0.3 W/K per metre. + expect(heatloss).to be_within(TOL).of(11.61) if id == "Office Left Wall" + expect(heatloss).to be_within(TOL).of(22.94) if id == "Office Front Wall" + end + end - json = TBD.process(model_FD, argh) + it "can sort multiple story-specific PSI sets (JSON input)" do + translator = OpenStudio::OSVersion::VersionTranslator.new + TBD.clean! + + file = File.join(__dir__, "files/osms/in/midrise.osm") + path = OpenStudio::Path.new(file) + model = translator.loadModel(path) + expect(model).to_not be_empty + model = model.get + + model.getSpaces.each do |space| + expect(space.thermalZone).to_not be_empty + zone = space.thermalZone.get + stpts = TBD.setpoints(space) + expect(TBD.plenum?(space)).to be false + expect(stpts).to have_key(:heating) + expect(stpts).to have_key(:cooling) + + if zone.nameString == "Office ZN" + expect(stpts[:heating]).to be_within(0.1).of(21.1) + expect(stpts[:cooling]).to be_within(0.1).of(23.9) + else + expect(stpts[:heating]).to be_within(0.1).of(21.7) + expect(stpts[:cooling]).to be_within(0.1).of(24.4) + end + end + + argh = {} + argh[:option ] = "(non thermal bridging)" # overridden + argh[:io_path ] = File.join(__dir__, "../json/midrise.json") + argh[:schema_path] = File.join(__dir__, "../tbd.schema.json") + + 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 # surfaces have already been derated - expect(TBD.logs.size).to eq(12) + expect(TBD.status).to be_zero + expect(TBD.logs).to be_empty expect(surfaces).to be_a(Hash) - expect(surfaces.size).to eq(23) - expect(surfaces).to have_key(wll) + expect(surfaces.size).to eq(180) expect(io).to be_a(Hash) + expect(io).to have_key(:stories) expect(io).to have_key(:edges) - expect(io[:edges].size).to eq(305) - - expect(surfaces[wll]).to have_key(:windows) - wins = surfaces[wll][:windows] - expect(wins.size).to eq(3) - expect(wins).to have_key("clerestory") - expect(wins).to have_key(win) - - expect(wins["clerestory"]).to have_key(:points) - expect(wins[win ]).to have_key(:points) + expect(io[:edges].size).to eq(282) - v1 = window_FD.vertices # original OSM vertices for window - f1 = TBD.offset(v1, width, 300) # offset vertices, forcing v300 version - expect(f1).to be_a(OpenStudio::Point3dVector) - expect(f1.size).to eq(4) + surfaces.each do |id, surface| + expect(surface).to have_key(:conditioned) + next unless surface[:conditioned] - f1.each { |f| expect(f).to be_a(OpenStudio::Point3d) } + expect(surface).to have_key(:heating) + expect(surface).to have_key(:cooling) + end - f1area = OpenStudio.getArea(f1) - expect(f1area).to_not be_empty - f1area = f1area.get + # A side test. Validating that TBD doesn't tag shared edge between exterior + # wall and interior ceiling (adiabatic conditions) as 'party' for + # 'multiplied' mid-level spaces. In fact, there shouldn't be a single + # instance of a 'party' edge in the TBD model. + surfaces.each do |id, surface| + next unless surface.key?(:ratio) - expect(f1area).to be_within(TOL).of(5.89 ) - expect(f1area).to be_within(TOL).of(wins[win][:area ]) - expect(f1area).to be_within(TOL).of(wins[win][:gross]) + expect(surface).to have_key(:edges) - # For SDK versions prior to v321, the offset vertices are generated in the - # right order with respect to the original subsurface vertices. - expect((f1[0].x - v1[0].x).abs).to be_within(TOL).of(width) - expect((f1[1].x - v1[1].x).abs).to be_within(TOL).of(width) - expect((f1[2].x - v1[2].x).abs).to be_within(TOL).of(width) - expect((f1[3].x - v1[3].x).abs).to be_within(TOL).of(width) - expect((f1[0].y - v1[0].y).abs).to be_within(TOL).of(0 ) - expect((f1[1].y - v1[1].y).abs).to be_within(TOL).of(0 ) - expect((f1[2].y - v1[2].y).abs).to be_within(TOL).of(0 ) - expect((f1[3].y - v1[3].y).abs).to be_within(TOL).of(0 ) - expect((f1[0].z - v1[0].z).abs).to be_within(TOL).of(width) - expect((f1[1].z - v1[1].z).abs).to be_within(TOL).of(width) - expect((f1[2].z - v1[2].z).abs).to be_within(TOL).of(width) - expect((f1[3].z - v1[3].z).abs).to be_within(TOL).of(width) + surface[:edges].values.each do |edge| + expect(edge).to have_key(:type) + expect(edge[:type]).to_not eq(:party) + end + end - v2 = clerestory.vertices - p2 = wins["clerestory"][:points] # same as original OSM vertices + expect(io[:stories].size).to eq(3) + stories = ["Building Story 1", "Building Story 2", "Building Story 3"] + types = [:parapetconvex, :transition] - expect((p2[0].x - v2[0].x).abs).to be_within(TOL).of(0) - expect((p2[1].x - v2[1].x).abs).to be_within(TOL).of(0) - expect((p2[2].x - v2[2].x).abs).to be_within(TOL).of(0) - expect((p2[3].x - v2[3].x).abs).to be_within(TOL).of(0) - expect((p2[0].y - v2[0].y).abs).to be_within(TOL).of(0) - expect((p2[1].y - v2[1].y).abs).to be_within(TOL).of(0) - expect((p2[2].y - v2[2].y).abs).to be_within(TOL).of(0) - expect((p2[3].y - v2[3].y).abs).to be_within(TOL).of(0) - expect((p2[0].z - v2[0].z).abs).to be_within(TOL).of(0) - expect((p2[1].z - v2[1].z).abs).to be_within(TOL).of(0) - expect((p2[2].z - v2[2].z).abs).to be_within(TOL).of(0) - expect((p2[3].z - v2[3].z).abs).to be_within(TOL).of(0) + io[:stories].each do |story| + expect(story).to have_key(:psi) + expect(story).to have_key(:id) + expect(stories).to include(story[:id]) + end - # In addition, the top of the "Office Front Wall Window 1" is aligned with - # the bottom of the clerestory, i.e. no conflicts between siblings. - expect((f1[0].z - p2[1].z).abs).to be_within(TOL).of(0) - expect((f1[3].z - p2[2].z).abs).to be_within(TOL).of(0) - expect(TBD.warn?).to be true + counter = 0 - # Testing both 'fits?' & 'overlaps?' functions. - TBD.clean! - vec2 = OpenStudio::Point3dVector.new + surfaces.each do |id, surface| + next unless surface.key?(:ratio) - p2.each { |p| vec2 << OpenStudio::Point3d.new(p.x, p.y, p.z) } + expect(surface).to have_key(:story) + expect(surface).to have_key(:boundary) + expect(surface[:boundary]).to eq("outdoors") - expect(TBD.fits?(f1, vec2)).to be false - expect(TBD.overlaps?(f1, vec2)).to be false - expect(TBD.status).to be_zero + nom = surface[:story].nameString + expect(stories).to include(nom) + expect(nom).to eq(stories[0]) if id.include?("g ") + expect(nom).to eq(stories[1]) if id.include?("m ") + expect(nom).to eq(stories[2]) if id.include?("t ") + expect(surface).to have_key(:edges) - # Same exercise, yet provide clerestory with Frame & Divider. - fd2 = OpenStudio::Model::WindowPropertyFrameAndDivider.new(model_FD) - width2 = 0.06 - expect(fd2.setFrameWidth(width2)).to be true - expect(fd2.setFrameConductance(2.500)).to be true - expect(clerestory.allowWindowPropertyFrameAndDivider).to be true - expect(clerestory.setWindowPropertyFrameAndDivider(fd2)).to be true - width3 = clerestory.windowPropertyFrameAndDivider.get.frameWidth - expect(width3).to be_within(TOL).of(width2) + counter += 1 - argh = { option: "poor (BETBG)" } + # Illustrating that story-specific PSI set is used when only 1x story. + surface[:edges].values.each do |edge| + expect(edge).to have_key(:type) + expect(edge).to have_key(:psi) + next unless id.include?("Roof") - json = TBD.process(model_FD, 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.error?).to be true # conflict between F&D windows - expect(TBD.logs.size).to eq(13) - expect(surfaces).to be_a(Hash) - expect(surfaces.size).to eq(23) - expect(surfaces).to have_key(wll) - expect(io).to be_a(Hash) - expect(io).to have_key(:edges) - expect(io[:edges].size).to eq(304) + expect(types).to include(edge[:type]) + next unless edge[:type] == :parapetconvex + next if id == "t Roof C" - expect(surfaces[wll]).to have_key(:windows) - wins = surfaces[wll][:windows] - expect(wins.size).to eq(3) - expect(wins).to have_key("clerestory") - expect(wins).to have_key(win) - expect(wins["clerestory"]).to have_key(:points) - expect(wins[win ]).to have_key(:points) + expect(edge[:psi]).to be_within(TOL).of(0.178) # 57.3% of 0.311 + end - # As there are conflicts between both windows (due to conflicting Frame & - # Divider parameters), TBD will ignore Frame & Divider coordinates and fall - # back to original OpenStudio subsurface vertices. - v1 = window_FD.vertices # original OSM vertices for window - p1 = wins[win][:points] # Topolys vertices, as original + # Illustrating that story-specific PSI set is used when only 1x story. + surface[:edges].values.each do |edge| + next unless id.include?("t ") + next unless id.include?("Wall ") + next unless edge[:type] == :parapetconvex + next if id.include?(" C") - expect((p1[0].x - v1[0].x).abs).to be_within(TOL).of(0) - expect((p1[1].x - v1[1].x).abs).to be_within(TOL).of(0) - expect((p1[2].x - v1[2].x).abs).to be_within(TOL).of(0) - expect((p1[3].x - v1[3].x).abs).to be_within(TOL).of(0) - expect((p1[0].y - v1[0].y).abs).to be_within(TOL).of(0) - expect((p1[1].y - v1[1].y).abs).to be_within(TOL).of(0) - expect((p1[2].y - v1[2].y).abs).to be_within(TOL).of(0) - expect((p1[3].y - v1[3].y).abs).to be_within(TOL).of(0) - expect((p1[0].z - v1[0].z).abs).to be_within(TOL).of(0) - expect((p1[1].z - v1[1].z).abs).to be_within(TOL).of(0) - expect((p1[2].z - v1[2].z).abs).to be_within(TOL).of(0) - expect((p1[3].z - v1[3].z).abs).to be_within(TOL).of(0) + expect(edge[:psi]).to be_within(TOL).of(0.133) # 42.7% of 0.311 + end - v2 = clerestory.vertices - p2 = wins["clerestory"][:points] # same as original OSM vertices + # The shared :rimjoist between middle story and ground floor units could + # either inherit the "Building Story 1" or "Building Story 2" :rimjoist + # PSI values. TBD retains the most conductive PSI values in such cases. + surface[:edges].values.each do |edge| + next unless id.include?("m ") + next unless id.include?("Wall ") + next if id.include?(" C") + next unless edge[:type] == :rimjoist - expect((p2[0].x - v2[0].x).abs).to be_within(TOL).of(0) - expect((p2[1].x - v2[1].x).abs).to be_within(TOL).of(0) - expect((p2[2].x - v2[2].x).abs).to be_within(TOL).of(0) - expect((p2[3].x - v2[3].x).abs).to be_within(TOL).of(0) - expect((p2[0].y - v2[0].y).abs).to be_within(TOL).of(0) - expect((p2[1].y - v2[1].y).abs).to be_within(TOL).of(0) - expect((p2[2].y - v2[2].y).abs).to be_within(TOL).of(0) - expect((p2[3].y - v2[3].y).abs).to be_within(TOL).of(0) - expect((p2[0].z - v2[0].z).abs).to be_within(TOL).of(0) - expect((p2[1].z - v2[1].z).abs).to be_within(TOL).of(0) - expect((p2[2].z - v2[2].z).abs).to be_within(TOL).of(0) - expect((p2[3].z - v2[3].z).abs).to be_within(TOL).of(0) + # Inheriting "Building Story 1" :rimjoist PSI of 0.501 W/K per metre. + # The SEA unit is above an office space below, which has curtain wall. + # RSi of insulation layers (to derate): + # - office walls : 0.740 m2.K/W (26.1%) + # - SEA walls : 2.100 m2.K/W (73.9%) + # + # - SEA walls : 26.1% of 0.501 = 0.3702 W/K per metre + # - other walls : 50.0% of 0.501 = 0.2505 W/K per metre + if ["m SWall SEA", "m EWall SEA"].include?(id) + expect(edge[:psi]).to be_within(0.002).of(0.3702) + else + expect(edge[:psi]).to be_within(0.002).of(0.2505) + end + end + end - # In addition, the top of the "Office Front Wall Window 1" is no longer - # aligned with the bottom of the clerestory. - expect(((p1[0].z - p2[1].z).abs - width).abs).to be_within(TOL).of(0) - expect(((p1[3].z - p2[2].z).abs - width).abs).to be_within(TOL).of(0) + expect(counter).to eq(51) + end + it "can process seb.osm (UNCONDITIONED attic)" do + translator = OpenStudio::OSVersion::VersionTranslator.new + version = OpenStudio.openStudioVersion.split(".").join.to_i TBD.clean! - vec1 = OpenStudio::Point3dVector.new - vec2 = OpenStudio::Point3dVector.new - p1.each { |p| vec1 << OpenStudio::Point3d.new(p.x, p.y, p.z) } - p2.each { |p| vec2 << OpenStudio::Point3d.new(p.x, p.y, p.z) } + file = File.join(__dir__, "files/osms/in/seb.osm") + path = OpenStudio::Path.new(file) + model = translator.loadModel(path) + expect(model).to_not be_empty + model = model.get - expect(TBD.fits?(vec1, vec2)).to be false - expect(TBD.overlaps?(vec1, vec2)).to be false + # Consider the plenum as UNCONDITIONED - not indirectly-conditioned. + plnum = model.getSpaceByName("Level 0 Ceiling Plenum") + expect(plnum).to_not be_empty + plnum = plnum.get + expect(TBD.unconditioned?(plnum)).to be false + + key = "space_conditioning_category" + val = "Unconditioned" + expect(plnum.additionalProperties.hasFeature(key)).to be false + expect(plnum.additionalProperties.setFeature(key, val)).to be true + expect(TBD.plenum?(plnum)).to be true # fyi, still has "plenum" spacetype + expect(TBD.unconditioned?(plnum)).to be true # ... more reliable + expect(TBD.setpoints(plnum)[:heating]).to be_nil + expect(TBD.setpoints(plnum)[:cooling]).to be_nil expect(TBD.status).to be_zero + model.getSurfaces.each do |s| + expect(s.space).to_not be_empty + 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 - # --- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- --- # - # Testing more complex cases, e.g. triangular windows, irregular 4-side - # windows, rough opening edges overlapping parent surface edges. There's - # overlap between this set of tests and a similar set in OSut. - model = OpenStudio::Model::Model.new - space = OpenStudio::Model::Space.new(model) - space.setName("Space") + id = c.nameString + name = s.nameString - # Windows are SimpleGlazing constructions. - fen = OpenStudio::Model::Construction.new(model) - glazing = OpenStudio::Model::SimpleGlazing.new(model) - layers = OpenStudio::Model::MaterialVector.new - fen.setName("FD fen") - glazing.setName("FD glazing") - expect(glazing.setUFactor(2.0)).to be true - layers << glazing - expect(fen.setLayers(layers)).to be true + if s.outsideBoundaryCondition.downcase == "outdoors" + expect(c.layers.size).to eq(4) + expect(c.layers[2].to_StandardOpaqueMaterial).to_not be_empty + m = c.layers[2].to_StandardOpaqueMaterial.get + r = m.thickness / m.thermalConductivity + expect(r).to be_within(TOL).of(1.47) if s.surfaceType == "Wall" + expect(r).to be_within(TOL).of(5.08) if s.surfaceType == "RoofCeiling" + elsif s.outsideBoundaryCondition == "Surface" + next unless s.surfaceType == "RoofCeiling" - # Frame & Divider object. - w000 = 0.000 - w200 = 0.200 # 0mm to 200mm (wide!) around glazing - fd = OpenStudio::Model::WindowPropertyFrameAndDivider.new(model) - fd.setName("FD") - expect(fd.setFrameConductance(0.500)).to be true - expect(fd.isFrameWidthDefaulted).to be true - expect(fd.frameWidth).to be_within(TOL).of(w000) + expect(c.layers.size).to eq(1) + expect(c.layers[0].to_StandardOpaqueMaterial).to_not be_empty + m = c.layers[0].to_StandardOpaqueMaterial.get + r = m.thickness / m.thermalConductivity + expect(r).to be_within(TOL).of(0.12) + end + end - # A square base wall surface: - v0 = OpenStudio::Point3dVector.new - v0 << OpenStudio::Point3d.new( 0.00, 0.00, 10.00) - v0 << OpenStudio::Point3d.new( 0.00, 0.00, 0.00) - v0 << OpenStudio::Point3d.new(10.00, 0.00, 0.00) - v0 << OpenStudio::Point3d.new(10.00, 0.00, 10.00) + # Save model as UNCONDITIONED. + file = File.join(__dir__, "files/osms/out/unconditioned.osm") + model.save(file, true) - # A first triangular window: - v1 = OpenStudio::Point3dVector.new - v1 << OpenStudio::Point3d.new( 2.00, 0.00, 8.00) - v1 << OpenStudio::Point3d.new( 1.00, 0.00, 6.00) - v1 << OpenStudio::Point3d.new( 4.00, 0.00, 9.00) + # The v1.11.5 (2016) seb.osm, shipped with OpenStudio, holds (what would now + # be considered as deprecated) a definition of plenum floors (i.e. ceiling + # tiles) generating quite a few warnings. From 'run/eplusout.err' (24.1.0): + # + # ** Warning ** GetSurfaceData: InterZone Surface Tilts do not match .... + # ** ~~~ ** Tilt=0.0 in Surface=LEVEL 0 ENTRY WAY CEILING PLENUM ... + # ** ~~~ ** Tilt=0.0 in Surface=ENTRY WAY DROPPEDCEILING, Zone ... + # ** Warning ** GetSurfaceData: InterZone Surface Classes do not match ... + # ** ~~~ ** Surface="LEVEL 0 ENTRY WAY CEILING PLENUM DROPPEDCEILING" + # ** ~~~ ** Adjacent Surface="ENTRY WAY DROPPEDCEILING", surface ... + # ** ~~~ ** Other errors/warnings may follow about these surfaces. + # ** Warning ** GetSurfaceData: InterZone Surface Tilts do not match .... + # ** ~~~ ** Tilt=0.0 in Surface=LEVEL 0 OPEN AREA 1 CEILING PLENUM ... + # ** ~~~ ** Tilt=0.0 in Surface=OPEN AREA 1 DROPPEDCEILING, ... + # ** Warning ** GetSurfaceData: InterZone Surface Classes do not match .... + # ** ~~~ ** Surface="LEVEL 0 OPEN AREA 1 CEILING PLENUM DROPPEDCEILING", + # ** ~~~ ** Adjacent Surface="OPEN AREA 1 DROPPEDCEILING", surface ... + # ** ~~~ ** Other errors/warnings may follow about these surfaces. + # ** Warning ** GetSurfaceData: InterZone Surface Tilts do not match .... + # ** ~~~ ** Tilt=0.0 in Surface=LEVEL 0 SMALL OFFICE 1 CEILING ... + # ** ~~~ ** Tilt=0.0 in Surface=SMALL OFFICE 1 DROPPEDCEILING, ... + # ** Warning ** GetSurfaceData: InterZone Surface Classes do not match .... + # ** ~~~ ** Surface="LEVEL 0 SMALL OFFICE 1 CEILING PLENUM ... + # ** ~~~ ** Adjacent Surface="SMALL OFFICE 1 DROPPEDCEILING", ... + # ** ~~~ ** Other errors/warnings may follow about these surfaces. + # ** Warning ** GetSurfaceData: InterZone Surface Tilts do not match .... + # ** ~~~ ** Tilt=0.0 in Surface=LEVEL 0 UTILITY 1 CEILING PLENUM ... + # ** ~~~ ** Tilt=0.0 in Surface=UTILITY 1 DROPPEDCEILING, ... + # ** Warning ** GetSurfaceData: InterZone Surface Classes do not match .... + # ** ~~~ ** Surface="LEVEL 0 UTILITY 1 CEILING PLENUM DROPPEDCEILING", + # ** ~~~ ** Adjacent Surface="UTILITY 1 DROPPEDCEILING", surface ... + # ** ~~~ ** Other errors/warnings may follow about these surfaces. + # ** Warning ** No floor exists in Zone="LEVEL 0 CEILING PLENUM ZONE", ... + # ** Warning ** CalculateZoneVolume: 1 zone is not fully enclosed ... - # A larger, irregular window: - v2 = OpenStudio::Point3dVector.new - v2 << OpenStudio::Point3d.new( 7.00, 0.00, 4.00) - v2 << OpenStudio::Point3d.new( 4.00, 0.00, 1.00) - v2 << OpenStudio::Point3d.new( 8.00, 0.00, 2.00) - v2 << OpenStudio::Point3d.new( 9.00, 0.00, 3.00) + # Ensuring TBD similarly derates model surfaces, before vs after the fix. In + # other words, TBD doesn't trip over a plenum "Floor" vs "RoofCeiling" when + # the plenum is UNCONDITIONED like a vented attic. + 2.times do |time| + unless time.zero? + file = File.join(__dir__, "files/osms/out/unconditioned.osm") + path = OpenStudio::Path.new(file) + model = translator.loadModel(path) + expect(model).to_not be_empty + model = model.get - # A final triangular window, near the wall's upper right corner: - v3 = OpenStudio::Point3dVector.new - v3 << OpenStudio::Point3d.new( 9.00, 0.00, 9.80) - v3 << OpenStudio::Point3d.new( 9.80, 0.00, 9.00) - v3 << OpenStudio::Point3d.new( 9.80, 0.00, 9.80) + # "Shading Surface 4" is overlapping with a plenum exterior wall. + sh4 = model.getShadingSurfaceByName("Shading Surface 4") + expect(sh4).to_not be_empty + sh4 = sh4.get + sh4.remove - w0 = OpenStudio::Model::Surface.new(v0, model) - w1 = OpenStudio::Model::SubSurface.new(v1, model) - w2 = OpenStudio::Model::SubSurface.new(v2, model) - w3 = OpenStudio::Model::SubSurface.new(v3, model) - w0.setName("w0") - w1.setName("w1") - w2.setName("w2") - w3.setName("w3") - expect(w0.setSpace(space)).to be true - sub_gross = 0 + plnum = model.getSpaceByName("Level 0 Ceiling Plenum") + expect(plnum).to_not be_empty + plnum = plnum.get + expect(TBD.unconditioned?(plnum)).to be true - [w1, w2, w3].each do |w| - expect(w.setSubSurfaceType("FixedWindow")).to be true - expect(w.setSurface(w0)).to be true - expect(w.setConstruction(fen)).to be true - expect(w.uFactor).to_not be_empty - expect(w.uFactor.get).to be_within(0.1).of(2.0) - expect(w.allowWindowPropertyFrameAndDivider).to be true - expect(w.setWindowPropertyFrameAndDivider(fd)).to be true - width = w.windowPropertyFrameAndDivider.get.frameWidth - expect(width).to be_within(TOL).of(w000) + thzone = plnum.thermalZone + expect(thzone).to_not be_empty + thzone = thzone.get - sub_gross += w.grossArea - end + # Before the fix. + unless version < 350 + expect(plnum.isEnclosedVolume).to be true + expect(plnum.isVolumeDefaulted).to be true + expect(plnum.isVolumeAutocalculated).to be true + end - expect(w1.grossArea).to be_within(TOL).of(1.50) - expect(w2.grossArea).to be_within(TOL).of(6.00) - expect(w3.grossArea).to be_within(TOL).of(0.32) - expect(w0.grossArea).to be_within(TOL).of(100.00) - expect(w1.netArea).to be_within(TOL).of(w1.grossArea) - expect(w2.netArea).to be_within(TOL).of(w2.grossArea) - expect(w3.netArea).to be_within(TOL).of(w3.grossArea) - expect(w0.netArea).to be_within(TOL).of(w0.grossArea - sub_gross) + if version > 350 && version < 370 + expect(plnum.volume.round(0)).to eq(234) + else + expect(plnum.volume.round(0)).to eq(0) + end - # Applying 2 sets of alterations: - # - without, then with Frame & Dividers (F&D) - # - 3 successive 20deg rotations around: - angle = Math::PI / 9 - origin = OpenStudio::Point3d.new(0, 0, 0) - east = OpenStudio::Point3d.new(1, 0, 0) - origin - up = OpenStudio::Point3d.new(0, 0, 1) - origin - north = OpenStudio::Point3d.new(0, 1, 0) - origin + expect(thzone.isVolumeDefaulted).to be true + expect(thzone.isVolumeAutocalculated).to be true + expect(thzone.volume).to be_empty - 4.times.each do |i| # successive rotations - unless i.zero? - r = OpenStudio.createRotation(origin, east, angle) if i == 1 - r = OpenStudio.createRotation(origin, up, angle) if i == 2 - r = OpenStudio.createRotation(origin, north, angle) if i == 3 - expect(w0.setVertices(r.inverse * w0.vertices)).to be true - expect(w1.setVertices(r.inverse * w1.vertices)).to be true - expect(w2.setVertices(r.inverse * w2.vertices)).to be true - expect(w3.setVertices(r.inverse * w3.vertices)).to be true - end + plnum.surfaces.each do |s| + next if s.outsideBoundaryCondition.downcase == "outdoors" - 2.times.each do |j| # F&D - if j.zero? - wx = w000 - fd.resetFrameWidth unless i.zero? - else - wx = w200 - expect(fd.setFrameWidth(wx)).to be true + # If a SEB plenum surface isn't facing outdoors, it's 1 of 4 "floor" + # surfaces (each facing a ceiling surface below). + adj = s.adjacentSurface + expect(adj).to_not be_empty + adj = adj.get + expect(adj.vertices.size).to eq(s.vertices.size) - [w1, w2, w3].each do |w| - width = w.windowPropertyFrameAndDivider.get.frameWidth - expect(width).to be_within(TOL).of(wx) + # Same vertex sequence? Should be in reverse order. + adj.vertices.each_with_index do |vertex, i| + expect(TBD.same?(vertex, s.vertices.at(i))).to be true + end + + expect(adj.surfaceType).to eq("RoofCeiling") + expect(s.surfaceType).to eq("RoofCeiling") + expect(s.setSurfaceType("Floor")).to be true + expect(s.setVertices(s.vertices.reverse)).to be true + + # Vertices now in reverse order. + adj.vertices.reverse.each_with_index do |vertex, i| + expect(TBD.same?(vertex, s.vertices.at(i))).to be true end end - # TBD's 'properties' relies on OSut's 'offset' solution when dealing - # with subsurfaces with F&D. It offers 3x options: - # 1. native, 3D vector-based calculations (only option for OS < v321) - # 2. SDK's reliance on Boost's 'buffer' (default for v321 < OS < v340) - # 3. SDK's 'rough opening' vertices (default for SDK v340+) - # - # Options #2 & #3 both rely on Boost's 'buffer' method. But SDK v340+ - # doesn't correct Boost-generated vertices (back to counterclockwise). - # Option #2 ensures counterclockwise sequences, although the first - # vertex in the array is no longer in sync with the original OpenStudio - # vertices. Not consequential for fitting and overlapping detection, or - # net/gross/rough areas tallies. Otherwise, both options generate the - # same vertices. - # - # For triangular subsurfaces, Options #2 & #3 may generate additional - # vertices near acute angles, e.g. 6 (3 of which would be ~colinear). - # Calculated areas, as well as fitting & overlapping detection, still - # work. Yet inaccuracies do creep in with respect to Option #1. To - # maintain consistency in TBD calculations when switching SDK versions, - # TBD's use of OSut's offset method is as follows (see 'properties' in - # geo.rb): - # - # offset(s.vertices, width, 300) - # - # There may be slight differences in reported SDK results vs TBD UA - # reports (e.g. WWR, net areas) with acute triangular windows ... which - # is fine. - surface = TBD.properties(w0, argh) - expect(surface).to_not be_nil - expect(surface).to be_a(Hash) - expect(surface).to have_key(:gross) - expect(surface).to have_key(:net) - expect(surface).to have_key(:windows) - expect(surface[:gross]).to be_a(Numeric) - expect(surface[:gross]).to be_within(0.1).of(100) - expect(surface[:windows]).to be_a(Hash) - expect(surface[:windows]).to have_key("w1") - expect(surface[:windows]).to have_key("w2") - expect(surface[:windows]).to have_key("w3") - expect(surface[:windows]["w1"]).to be_a(Hash) - expect(surface[:windows]["w2"]).to be_a(Hash) - expect(surface[:windows]["w3"]).to be_a(Hash) - expect(surface[:windows]["w1"]).to have_key(:gross) - expect(surface[:windows]["w2"]).to have_key(:gross) - expect(surface[:windows]["w3"]).to have_key(:gross) - expect(surface[:windows]["w1"]).to have_key(:points) - expect(surface[:windows]["w2"]).to have_key(:points) - expect(surface[:windows]["w3"]).to have_key(:points) - expect(surface[:windows]["w1"][:points].size).to eq(3) - expect(surface[:windows]["w2"][:points].size).to eq(4) - expect(surface[:windows]["w3"][:points].size).to eq(3) + # Save for future testing. + file = File.join(__dir__, "files/osms/out/unconditioned2.osm") + model.save(file, true) - if j.zero? - expect(surface[:windows]["w1"][:gross]).to be_within(TOL).of(1.50) - expect(surface[:windows]["w2"][:gross]).to be_within(TOL).of(6.00) - expect(surface[:windows]["w3"][:gross]).to be_within(TOL).of(0.32) - else - expect(surface[:windows]["w1"][:gross]).to be_within(TOL).of(3.75) - expect(surface[:windows]["w2"][:gross]).to be_within(TOL).of(8.64) - expect(surface[:windows]["w3"][:gross]).to be_within(TOL).of(1.10) + # After the fix. + unless version < 350 + expect(plnum.isEnclosedVolume).to be true + expect(plnum.isVolumeDefaulted).to be true + expect(plnum.isVolumeAutocalculated).to be true end + + expect(plnum.volume.round(0)).to eq(50) + expect(thzone.isVolumeDefaulted).to be true + expect(thzone.isVolumeAutocalculated).to be true + expect(thzone.volume).to be_empty end - end - # Neither warning nor error == no conflicts between windows (with new - # new vertices offset by 200mm) and with the base wall. - expect(TBD.status).to be_zero - end + argh = {option: "poor (BETBG)"} - it "can flag errors and integrate TBD logs in JSON output" do - translator = OpenStudio::OSVersion::VersionTranslator.new - TBD.clean! + 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.status).to be_zero + expect(TBD.logs).to be_empty + expect(surfaces).to be_a(Hash) + expect(surfaces.size).to eq(56) + expect(io).to be_a(Hash) + expect(io).to have_key(:edges) + expect(io[:edges].size).to eq(80) - 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 + edges = io[:edges] + edges = edges.reject { |s| s.to_s.include?("sill" ) } + edges = edges.reject { |s| s.to_s.include?("head" ) } + edges = edges.reject { |s| s.to_s.include?("jamb" ) } + edges = edges.reject { |s| s.to_s.include?("grade" ) } + edges = edges.reject { |s| s.to_s.include?("corner") } + edges = edges.reject { |s| s.to_s.include?("sill" ) } - office = model.getSpaceByName("Zone1 Office") - expect(office).to_not be_empty + expect(edges.size).to eq(26) - front_office_wall = model.getSurfaceByName("Office Front Wall") - expect(front_office_wall).to_not be_empty - front_office_wall = front_office_wall.get - expect(front_office_wall.nameString).to eq("Office Front Wall") - expect(front_office_wall.surfaceType).to eq("Wall") + edges.each do |edge| + type = edge[:type ] + size = edge[:surfaces].size + shades = edge[:surfaces].select { |s| s.include?("Shading") } + walls = edge[:surfaces].select { |s| s.include?("Wall") } + ceilings = edge[:surfaces].select { |s| s.include?("DroppedCeiling") } - left_office_wall = model.getSurfaceByName("Office Left Wall") - expect(left_office_wall).to_not be_empty - left_office_wall = left_office_wall.get - expect(left_office_wall.nameString).to eq("Office Left Wall") - expect(left_office_wall.surfaceType).to eq("Wall") + pceilings = ceilings.select { |s| s.include?("Plenum") } + expect(type).to eq(:transition).or eq(:parapetconvex) - right_fine_wall = model.getSurfaceByName("Fine Storage Right Wall") - expect(right_fine_wall).to_not be_empty - right_fine_wall = right_fine_wall.get - expect(right_fine_wall.nameString).to eq("Fine Storage Right Wall") - expect(right_fine_wall.surfaceType).to eq("Wall") + if type == :transition + if size == 2 || size == 4 + expect(walls.size).to eq(size) + elsif size == 3 + expect(shades.size).to eq(1) + expect(walls.size).to eq(2) + else + expect(size).to eq(6) + # ... shared between: + # - 1x paired interior walls = 2x + # - 2x pairs of adjacent ceilings (either side) = 4x + # ___________________________________________ = 6x in TOTAL + expect(walls.size).to eq(2) + expect(ceilings.size).to eq(4) + expect(pceilings.size).to eq(2) + end + else + # ... shared between: + # - 1x exterior wall (occupied space) = 1x + # - 1x plenum wall overhead = 1x + # - 1x shading (maybe) + # - 1x pair of adjacent ceilings (either side) = 2x + # _____________________________________________ = 4x (or 5x) in TOTAL + if size == 5 + expect(shades.size).to eq(1) + else + expect(size).to eq(4) + expect(shades.size).to eq(0) + end - # Adding a small, 5-sided window to the "Office Front Wall" (above door). - os_v = OpenStudio::Point3dVector.new - os_v << OpenStudio::Point3d.new( 12.96, 0.00, 4.00) - os_v << OpenStudio::Point3d.new( 12.04, 0.00, 3.50) - os_v << OpenStudio::Point3d.new( 12.04, 0.00, 2.50) - os_v << OpenStudio::Point3d.new( 13.87, 0.00, 2.50) - os_v << OpenStudio::Point3d.new( 13.87, 0.00, 3.50) - clerestory = OpenStudio::Model::SubSurface.new(os_v, model) - clerestory.setName("clerestory") - expect(clerestory.setSurface(front_office_wall)).to be true - expect(clerestory.setSubSurfaceType("FixedWindow")).to be true - # ... reminder: set subsurface type AFTER setting its parent surface. + expect(walls.size).to eq(2) + expect(ceilings.size).to eq(2) + expect(pceilings.size).to eq(1) + end + end - # A new, highly-conductive material. - material = OpenStudio::Model::MasslessOpaqueMaterial.new(model) - material.setName("poor material") - expect(material.nameString).to eq("poor material") - expect(material.setThermalResistance(RMIN)).to be true - mat = OpenStudio::Model::MaterialVector.new - mat << material + ids = { a: "Entryway Wall 4", + b: "Entryway Wall 5", + c: "Entryway Wall 6", + d: "Entry way DroppedCeiling", + e: "Utility1 Wall 1", + f: "Utility1 Wall 5", + g: "Utility 1 DroppedCeiling", + h: "Smalloffice 1 Wall 1", + i: "Smalloffice 1 Wall 2", + j: "Smalloffice 1 Wall 6", + k: "Small office 1 DroppedCeiling", + l: "Openarea 1 Wall 3", + m: "Openarea 1 Wall 4", + n: "Openarea 1 Wall 5", + o: "Openarea 1 Wall 6", + p: "Openarea 1 Wall 7", + q: "Open area 1 DroppedCeiling" + }.freeze - # A 'standard' variant (RMIN) - material2 = OpenStudio::Model::StandardOpaqueMaterial.new(model) - material2.setName("poor material2") - expect(material2.nameString).to eq("poor material2") - expect(material2.setThermalConductivity(KMAX)).to be true - expect(material2.setThickness(DMIN)).to be true - mat2 = OpenStudio::Model::MaterialVector.new - mat2 << material2 + surfaces.each do |id, surface| + expect(surface).to have_key(:deratable) + expect(surface).to have_key(:conditioned) + expect(surface).to have_key(:space) + space = surface[:space] + next unless surface[:deratable] - # Another 'massless' material, whose name already includes " tbd". - material3 = OpenStudio::Model::MasslessOpaqueMaterial.new(model) - material3.setName("poor material m tbd") - expect(material3.nameString).to eq("poor material m tbd") - expect(material3.setThermalResistance(1.0)).to be true - expect(material3.thermalResistance).to be_within(0.1).of(1.0) - mat3 = OpenStudio::Model::MaterialVector.new - mat3 << material3 + expect(surface[:conditioned]).to be false if space == plnum + expect(surface[:conditioned]).to be true unless space == plnum + expect(ids).to_not have_value(id) if space == plnum + expect(ids).to have_value(id) unless space == plnum + next unless surface[:conditioned] - # Assign highly-conductive material to a new construction. - construction = OpenStudio::Model::Construction.new(model) - construction.setName("poor construction") - expect(construction.nameString).to eq("poor construction") - expect(construction.layers).to be_empty - expect(construction.setLayers(mat2)).to be true # or switch with 'mat' - expect(construction.layers.size).to eq(1) - - # Assign " tbd" massless material to a new construction. - construction2 = OpenStudio::Model::Construction.new(model) - construction2.setName("poor construction tbd") - expect(construction2.nameString).to eq("poor construction tbd") - expect(construction2.layers).to be_empty - expect(construction2.setLayers(mat3)).to be true - expect(construction2.layers.size).to eq(1) - - # Assign construction to the "Office Left Wall". - expect(left_office_wall.setConstruction(construction)).to be true - - # Assign construction2 to the "Fine Storage Right Wall". - expect(right_fine_wall.setConstruction(construction2)).to be true - - subs = front_office_wall.subSurfaces - expect(subs).to_not be_empty - expect(subs.size).to eq(4) - - argh = {} - argh[:option ] = "poor (BETBG)" - argh[:io_path ] = File.join(__dir__, "../json/tbd_warehouse9.json") - argh[:schema_path] = File.join(__dir__, "../tbd.schema.json") - # { - # "schema": "https://github.com/rd2/tbd/blob/master/tbd.schema.json", - # "description": "testing error detection", - # "psis": [ - # { - # "id": "detailed 2", - # "fen": 0.600 - # }, - # { - # "id": "regular (BETBG)", <<<< ERROR #1 - can't reset built-in sets - # "fen": 0.700 - # } - # ], - # "khis": [ - # { - # "id": "cantilevered beam", - # "point": 0.6 - # } - # ], - # "surfaces": [ - # { - # "id": "Office Front Wall", - # "khis": [ - # { - # "id": "beam", <<<< ERROR #2 - 'beam' not previously defined - # "count": 3 - # } - # ] - # }, - # { - # "id": "Office Left Wall", - # "khis": [ - # { - # "id": "cantilevered beam", - # "count": 300 <<<< WARNING #1 - heat loss too great (for m2) - # } - # ] - # } - # ], - # "edges": [ - # { - # "psi": "detailed", <<<< ERROR #3 - 'detailed' not previously defined - # "type": "fen", - # "surfaces": [ - # "Office Front Wall", - # "Office Front Wall Window 1" - # ] - # } - # ] - # } - - 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.error?).to be true - expect(TBD.logs.size).to eq(6) - expect(io).to be_a(Hash) - expect(surfaces).to be_a(Hash) - expect(surfaces.size).to eq(23) - expect(io).to be_a(Hash) - expect(io).to have_key(:edges) - expect(io[:edges].size).to eq(300) - - expect(surfaces).to have_key("Office Front Wall") - expect(surfaces).to have_key("Office Left Wall") - expect(surfaces).to have_key("Fine Storage Right Wall") - - expect(surfaces["Office Front Wall"]).to have_key(:edges) - expect(surfaces["Office Left Wall"]).to have_key(:edges) - expect(surfaces["Fine Storage Right Wall"]).to have_key(:edges) - - # TBD.logs.each { |log| puts log[:message] } - # Skipping 'clerestory': vertex # 3 or 4 (TBD::properties) - # 'regular (BETBG)': existing PSI set (TBD::append) - # JSON/KHI surface 'Office Front Wall' 'beam' (TBD::inputs) - # Missing edge PSI detailed (TBD::inputs) - # Won't derate 'poor construction tbd 1': tagged as derated (TBD::derate) - # Won't assign 197.714 W/K to 'Office Left Wall': too conductive (TBD::derate) - - # Despite input file (non-fatal) errors, TBD successfully processes thermal - # bridges and derates OSM construction materials by falling back on defaults - # in the case of errors. - - # For the 5-sided window, TBD will simply ignore all edges/bridges linked to - # the 'clerestory' subsurface. - io[:edges].each do |edge| - expect(edge).to have_key(:surfaces) - - edge[:surfaces].each { |s| expect(s).to_not eq("clerestory") } - end - - expect(surfaces["Office Front Wall"][:edges].size).to eq(17) - sills = 0 - - surfaces["Office Front Wall"][:edges].values.each do |e| - expect(e).to have_key(:type) - sills += 1 if e[:type] == :sill - end - - expect(sills).to eq(2) # not 3 - - # Fallback to ERROR # 1: not really a fallback, more a demonstration that - # "regular (BETBG)" isn't referred to by any edge-linked derated surfaces. - # ... & fallback to ERROR # 3: no edge relying on 'detailed' PSI set. - io[:edges].each { |edge| expect(edge[:psi]).to eq("poor (BETBG)") } - - # Fallback to ERROR # 2: no KHI for "Office Front Wall". - expect(io).to have_key(:khis) - expect(io[:khis].size).to eq(1) - expect(surfaces["Office Front Wall"]).to_not have_key(:khis) - - # ... concerning the "Office Left Wall" (underatable material). - left_office_wall = model.getSurfaceByName("Office Left Wall") - expect(left_office_wall).to_not be_empty - left_office_wall = left_office_wall.get - - c = left_office_wall.construction.get.to_LayeredConstruction.get - expect(c.numLayers).to eq(1) - layer = c.getLayer(0).to_StandardOpaqueMaterial - expect(layer).to_not be_empty - layer = layer.get - expect(layer.name.get).to eq("Office Left Wall m tbd") - expect(layer.thermalConductivity).to be_within(0.1).of(KMAX) - expect(layer.thickness).to be_within(0.001).of(DMIN) - - # Regardless of the targetted material type ('standard' vs 'massless'), TBD - # will ensure a minimal RSi value (see OSut RMIN), i.e. no derating despite - # the surface having thermal bridges. - expect(surfaces["Office Left Wall"]).to have_key(:heatloss) - expect(surfaces["Office Left Wall"]).to have_key(:r_heatloss) - - expect(surfaces["Office Left Wall"][:heatloss ]).to be_within(0.1).of(197.7) - expect(surfaces["Office Left Wall"][:r_heatloss]).to be_within(0.1).of(197.7) + expect(surface).to have_key(:edges) + expect(surface).to have_key(:heating) + expect(surface).to have_key(:cooling) + end - expect(surfaces["Fine Storage Right Wall"]).to have_key(:heatloss) - expect(surfaces["Fine Storage Right Wall"]).to_not have_key(:r_heatloss) + surfaces.each do |id, surface| + next unless surface.key?(:edges) - # Concerning the new material (with a name already including " tbd"): - # TBD ignores all such materials (a safeguard against iterative TBD - # runs). Contrary to the previous critical cases of highly conductive - # materials, TBD doesn't even try to set the :r_heatloss hash value - tough! - right_fine_wall = model.getSurfaceByName("Fine Storage Right Wall") - expect(right_fine_wall).to_not be_empty - right_fine_wall = right_fine_wall.get + expect(surface).to have_key(:ratio) + expect(surface).to have_key(:heatloss) + h = surface[:heatloss] + s = model.getSurfaceByName(id) + expect(s).to_not be_empty + s = s.get + expect(s.nameString).to eq(id) + expect(s.isConstructionDefaulted).to be false + expect(s.construction.get.nameString).to include(" tbd") + expect(h).to be_within(TOL).of( 6.43) if id == ids[:a] + expect(h).to be_within(TOL).of(11.18) if id == ids[:b] + expect(h).to be_within(TOL).of( 4.56) if id == ids[:c] + expect(h).to be_within(TOL).of( 0.42) if id == ids[:d] + expect(h).to be_within(TOL).of(12.66) if id == ids[:e] + expect(h).to be_within(TOL).of(12.59) if id == ids[:f] + expect(h).to be_within(TOL).of( 0.50) if id == ids[:g] + expect(h).to be_within(TOL).of(14.06) if id == ids[:h] + expect(h).to be_within(TOL).of( 9.04) if id == ids[:i] + expect(h).to be_within(TOL).of( 8.75) if id == ids[:j] + expect(h).to be_within(TOL).of( 0.53) if id == ids[:k] + expect(h).to be_within(TOL).of( 5.06) if id == ids[:l] + expect(h).to be_within(TOL).of( 6.25) if id == ids[:m] + expect(h).to be_within(TOL).of( 9.04) if id == ids[:n] + expect(h).to be_within(TOL).of( 6.74) if id == ids[:o] + expect(h).to be_within(TOL).of( 4.32) if id == ids[:p] + expect(h).to be_within(TOL).of( 0.76) if id == ids[:q] - c = right_fine_wall.construction.get.to_LayeredConstruction.get - layer = c.getLayer(0).to_MasslessOpaqueMaterial - expect(layer).to_not be_empty - layer = layer.get - expect(layer.name.get).to eq("poor material m tbd") - expect(layer.thermalResistance).to be_within(0.1).of(1.0) + c = s.construction + expect(c).to_not be_empty + c = c.get.to_LayeredConstruction + expect(c).to_not be_empty + c = c.get + i = 0 + i = 2 if s.outsideBoundaryCondition.downcase == "outdoors" + expect(c.layers[i].nameString).to include("m tbd") + end - # Mimics (somewhat) the TBD 'measure.rb' method 'exitTBD()' - # ... should generate a 'logs' entry at the of the JSON output file. - status = TBD.msg(TBD.status) - status = TBD.msg(INF) if TBD.status.zero? + surfaces.each do |id, surface| + expect(surface).to have_key(:filmRSI) - tbd_log = { date: Time.now, status: status } + if surface.key?(:ratio) + expect(surface[:ratio]).to be_within(0.1).of(-36.74) if id == ids[:a] + expect(surface[:ratio]).to be_within(0.1).of(-34.61) if id == ids[:b] + expect(surface[:ratio]).to be_within(0.1).of(-33.57) if id == ids[:c] + expect(surface[:ratio]).to be_within(0.1).of( -0.14) if id == ids[:d] + expect(surface[:ratio]).to be_within(0.1).of(-35.09) if id == ids[:e] + expect(surface[:ratio]).to be_within(0.1).of(-35.12) if id == ids[:f] + expect(surface[:ratio]).to be_within(0.1).of( -0.13) if id == ids[:g] + expect(surface[:ratio]).to be_within(0.1).of(-39.75) if id == ids[:h] + expect(surface[:ratio]).to be_within(0.1).of(-39.74) if id == ids[:i] + expect(surface[:ratio]).to be_within(0.1).of(-39.90) if id == ids[:j] + expect(surface[:ratio]).to be_within(0.1).of( -0.13) if id == ids[:k] + expect(surface[:ratio]).to be_within(0.1).of(-27.78) if id == ids[:l] + expect(surface[:ratio]).to be_within(0.1).of(-31.66) if id == ids[:m] + expect(surface[:ratio]).to be_within(0.1).of(-28.44) if id == ids[:n] + expect(surface[:ratio]).to be_within(0.1).of(-30.85) if id == ids[:o] + expect(surface[:ratio]).to be_within(0.1).of(-28.78) if id == ids[:p] + expect(surface[:ratio]).to be_within(0.1).of( -0.09) if id == ids[:q] + next unless id == ids[:a] - results = [] + s = model.getSurfaceByName(id) + expect(s).to_not be_empty + s = s.get + expect(s.nameString).to eq(id) + expect(s.surfaceType).to eq("Wall") + expect(s.isConstructionDefaulted).to be false + c = s.construction.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) + expect(c.layers[2].nameString).to include("m tbd") + expect(c.layers[2].to_StandardOpaqueMaterial).to_not be_empty + m = c.layers[2].to_StandardOpaqueMaterial.get - if surfaces - surfaces.each do |id, surface| - next if TBD.fatal? - next unless surface.key?(:ratio) + initial_R = surface[:filmRSI] + 2.4674 + derated_R = surface[:filmRSI] + 0.9931 + derated_R += m.thickness / m.thermalConductivity - ratio = format "%3.1f", surface[:ratio] - name = id.rjust(15, " ") - output = "#{name} RSi derated by #{ratio}%" - results << output + ratio = -(initial_R - derated_R) * 100 / initial_R + expect(ratio).to be_within(1).of(surfaces[id][:ratio]) + else + if surface[:boundary] == "outdoors" + expect(surface[:conditioned]).to be false + end + end end end - tbd_log[:results] = results unless results.empty? - tbd_msgs = [] - - TBD.logs.each do |l| - tbd_msgs << { level: TBD.tag(l[:level]), message: l[:message] } - end - - tbd_log[:messages] = tbd_msgs unless tbd_msgs.empty? - - io[:log] = tbd_log - - # Deterministic sorting - io[:schema ] = io.delete(:schema ) if io.key?(:schema) - io[:description] = io.delete(:description) if io.key?(:description) - io[:log ] = io.delete(:log ) if io.key?(:log) - io[:psis ] = io.delete(:psis ) if io.key?(:psis) - io[:khis ] = io.delete(:khis ) if io.key?(:khis) - io[:building ] = io.delete(:building ) if io.key?(:building) - io[:stories ] = io.delete(:stories ) if io.key?(:stories) - io[:spacetypes ] = io.delete(:spacetypes ) if io.key?(:spacetypes) - io[:spaces ] = io.delete(:spaces ) if io.key?(:spaces) - io[:surfaces ] = io.delete(:surfaces ) if io.key?(:surfaces) - io[:edges ] = io.delete(:edges ) if io.key?(:edges) - - out = JSON.pretty_generate(io) - outP = File.join(__dir__, "../json/tbd_warehouse9.out.json") - File.open(outP, "w") { |outP| outP.puts out } - # ... should contain 'log' entries at the start of the JSON output file. + # MODEL VARIANT annual GJ (PRE-TBD) + # ________________________ _________ + # unconditioned SEB 257.04 + # fixed unconditioned SEB 258.40 + # ________________________ _________ + # +1.36 (+0.5%) ... QC City, OS v3.6.1 + # + # A diff comparison of both generated .osm files do not reveal changes other + # than the aforementioned fixes (before running TBD). Boils down to removing + # the fixed shading? "Floor" vs "RoofCeiling" heat transfer coefficients? + # In any case, GJ differences are about the same (pre- vs post-TBD). + # + # MODEL VARIANT annual GJ (POST-TBD) + # ________________________ _________ + # unconditioned SEB 262.70 + # fixed unconditioned SEB 264.05 + # ________________________ _________ + # +1.35 (+0.5%) ... QC City, OS v3.6.1 end - it "can process an OSM converted from an IDF (with rotation)" do + it "can process seb.osm (CONDITIONED plenum)" do translator = OpenStudio::OSVersion::VersionTranslator.new + version = OpenStudio.openStudioVersion.split(".").join.to_i TBD.clean! - file = File.join(__dir__, "files/osms/in/5Zone_2.osm") + file = File.join(__dir__, "files/osms/in/seb.osm") path = OpenStudio::Path.new(file) model = translator.loadModel(path) expect(model).to_not be_empty model = model.get - plnum = model.getSpaceByName("PLENUM-1") + + # Out of the box, plenum is INDIRECTLY-CONDITIONED - not UNCONDITIONED. + plnum = model.getSpaceByName("Level 0 Ceiling Plenum") expect(plnum).to_not be_empty plnum = plnum.get - model.getSpaces.each do |space| - stpts = TBD.setpoints(space) - expect(stpts).to have_key(:heating) - expect(stpts).to have_key(:cooling) - expect(TBD.plenum?(space)).to be false + expect(TBD.plenum?(plnum)).to be true # has "plenum" spacetype + expect(TBD.unconditioned?(plnum)).to be false + expect(TBD.setpoints(plnum)[:heating].to_i).to eq(21) + expect(TBD.setpoints(plnum)[:cooling].to_i).to eq(24) + expect(TBD.status).to be_zero - if space == plnum - expect(stpts[:heating]).to be_nil - expect(stpts[:cooling]).to be_nil - else - expect(stpts[:heating]).to be_within(0.1).of(22.2) - expect(stpts[:cooling]).to be_within(0.1).of(23.9) - end - end + # Contrary to the previous "seb.osm (UNCONDITIONED) attic" RSpec, the fix + # triggers TBD to label as ":ceiling" edges shared by: + # - 1x plenum "Floor" + # - 1x adjacent (occupied) room "RoofCeiling" + # - 1x plenum outdoor-facing "Wall" + # - 1x (occupied) room outdoor-facing "Wall" + # + # Before the fix, TBD labels these same edges as ":transition". In normal + # circumstances, this wouldn't usually affect simulation results, as both + # :transition and :ceiling PSI-factors would normally be set to 0.0 W/K per + # linear meter. But users remain free to reset either value, so ... + 2.times do |time| + unless time.zero? + file = File.join(__dir__, "files/osms/in/seb.osm") + path = OpenStudio::Path.new(file) + model = translator.loadModel(path) + expect(model).to_not be_empty + model = model.get + + # "Shading Surface 4" is overlapping with a plenum exterior wall. + sh4 = model.getShadingSurfaceByName("Shading Surface 4") + expect(sh4).to_not be_empty + sh4 = sh4.get + sh4.remove + + plnum = model.getSpaceByName("Level 0 Ceiling Plenum") + expect(plnum).to_not be_empty + plnum = plnum.get + + thzone = plnum.thermalZone + expect(thzone).to_not be_empty + thzone = thzone.get + + # Before the fix. + unless version < 350 + expect(plnum.isEnclosedVolume).to be true + expect(plnum.isVolumeDefaulted).to be true + expect(plnum.isVolumeAutocalculated).to be true + end + + if version > 350 && version < 370 + expect(plnum.volume.round(0)).to eq(234) + else + expect(plnum.volume.round(0)).to eq(0) + end + + expect(thzone.isVolumeDefaulted).to be true + expect(thzone.isVolumeAutocalculated).to be true + expect(thzone.volume).to be_empty + + plnum.surfaces.each do |s| + next if s.outsideBoundaryCondition.downcase == "outdoors" + + # If a SEB plenum surface isn't facing outdoors, it's 1 of 4 "floor" + # surfaces (each facing a ceiling surface below). + adj = s.adjacentSurface + expect(adj).to_not be_empty + adj = adj.get + expect(adj.vertices.size).to eq(s.vertices.size) + + # Same vertex sequence? Should be in reverse order. + adj.vertices.each_with_index do |vertex, i| + expect(TBD.same?(vertex, s.vertices.at(i))).to be true + end - # PLENUM floors. - flr_ids = ["C1-1P", "C2-1P", "C3-1P", "C4-1P", "C5-1P"] + expect(adj.surfaceType).to eq("RoofCeiling") + expect(s.surfaceType).to eq("RoofCeiling") + expect(s.setSurfaceType("Floor")).to be true + expect(s.setVertices(s.vertices.reverse)).to be true - floors = model.getSurfaces.select { |s| flr_ids.include?(s.nameString) } + # Vertices now in reverse order. + adj.vertices.reverse.each_with_index do |vertex, i| + expect(TBD.same?(vertex, s.vertices.at(i))).to be true + end + end - floors.each do |fl| - expect(flr_ids).to include(fl.nameString) - space = fl.space - expect(space).to_not be_empty - expect(space.get).to eq(plnum) + # Save for future testing. + file = File.join(__dir__, "files/osms/out/seb2.osm") + model.save(file, true) - c = fl.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 eq("CLNG-1") - expect(c.layers.size).to eq(1) - expect(c.layers[0].nameString).to eq("MAT-CLNG-1") # RSi 0.650 - end + # After the fix. + unless version < 350 + expect(plnum.isEnclosedVolume).to be true + expect(plnum.isVolumeDefaulted).to be true + expect(plnum.isVolumeAutocalculated).to be true + end - # Tracking outdoor-facing office walls. - walls = [] + expect(plnum.volume.round(0)).to eq(50) # right answer + expect(thzone.isVolumeDefaulted).to be true + expect(thzone.isVolumeAutocalculated).to be true + expect(thzone.volume).to be_empty + end - model.getSurfaces.each do |s| - next unless s.surfaceType.downcase == "wall" + argh = {option: "poor (BETBG)"} - walls << s if s.outsideBoundaryCondition.downcase == "outdoors" - end + 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.status).to be_zero + expect(TBD.logs).to be_empty + expect(surfaces).to be_a(Hash) + expect(surfaces.size).to eq(56) + expect(io).to be_a(Hash) + expect(io).to have_key(:edges) + expect(io[:edges].size).to eq(106) # not 80 as if it were UNCONDITIONED - expect(walls.size).to eq(8) + edges = io[:edges] + edges = edges.reject { |s| s.to_s.include?("sill" ) } + edges = edges.reject { |s| s.to_s.include?("head" ) } + edges = edges.reject { |s| s.to_s.include?("jamb" ) } + edges = edges.reject { |s| s.to_s.include?("grade" ) } + edges = edges.reject { |s| s.to_s.include?("corner") } + edges = edges.reject { |s| s.to_s.include?("sill" ) } - walls.each do |s| - 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(edges.size).to eq(44) - expect(c.nameString).to eq("WALL-1") - expect(c.layers.size).to eq(4) - expect(c.layers[0].nameString).to eq("WD01") # RSi 0.165 - expect(c.layers[1].nameString).to eq("PW03") # RSI 0.110 - expect(c.layers[2].nameString).to eq("IN02") # RSi 2.090 - expect(c.layers[3].nameString).to eq("GP01") # RSi 0.079 - end + edges.each do |edge| + type = edge[:type ] + size = edge[:surfaces].size + shades = edge[:surfaces].select { |s| s.include?("Shading") } + walls = edge[:surfaces].select { |s| s.include?("Wall") } + ceilings = edge[:surfaces].select { |s| s.include?("DroppedCeiling") } + roofs = edge[:surfaces].select { |s| s.include?("RoofCeiling") } - argh = { option: "poor (BETBG)" } + pceilings = ceilings.select { |s| s.include?("Plenum") } - 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.status).to be_zero - expect(TBD.logs).to be_empty - expect(surfaces).to be_a(Hash) - expect(surfaces.size).to eq(40) - expect(io).to be_a(Hash) - expect(io).to have_key(:edges) - expect(io[:edges].size).to eq(47) + expect(type).to eq(:transition).or eq(:parapetconvex).or eq(:ceiling) + expect(type).to_not eq(:ceiling) if time == 0 # :transition instead - doors = [] - derated = [] + if type == :transition + if time == 1 + expect(size).to eq(2).or eq(3).or eq(4) # not 5 + expect(walls.size).to eq(size) if size == 4 + else + expect(size).to eq(2).or eq(3).or eq(4).or eq(5) + end - ids = { a: "LEFT-1", - b: "RIGHT-1", - c: "FRONT-1", - d: "BACK-1", - e: "C1-1", # ceiling below plenum/attic - f: "C2-1", # " - g: "C3-1", # " - h: "C4-1", # " - i: "C5-1" # " - }.freeze + if size == 2 # between 2x exterior walls OR 2x plenum roof surfaces + next if walls.size == size - surfaces.each do |id, surface| - expect(surface).to have_key(:type) - expect(surface).to have_key(:conditioned) - next unless surface[:conditioned] - next unless surface.key?(:edges) + expect(walls.size).to eq(0) + expect(ceilings.size).to eq(0) + expect(roofs.size).to eq(2) + expect(pceilings.size).to eq(0) + elsif size == 3 + expect(shades.size).to eq(1) + expect(walls.size).to eq(2) + elsif size == 4 # between 2x room ceilings, along 2x exterior walls + next if walls.size == size - doors += surface[:doors].values if surface.key?(:doors) + # Holds "Shading Surface 4"? Then it's before the fix. + if shades.size == 2 + expect(time).to eq(0) + expect(walls.size).to eq(2) + expect(shades).to include("Shading Surface 4") + next + end - derated << id - expect(ids).to have_value(id) + expect(walls.size).to eq(2) + expect(ceilings.size).to eq(2) + expect(roofs.size).to eq(0) + expect(pceilings.size).to eq(1) + else + expect(time).to eq(0) + expect(size).to eq(5) + expect(shades.size).to eq(1) + expect(shades).to include("Shading Surface 4") + expect(walls.size).to eq(2) + expect(ceilings.size).to eq(2) + expect(roofs.size).to eq(0) + expect(pceilings.size).to eq(1) + end + elsif type == :parapetconvex + if size == 4 + expect(time).to eq(0) + expect(shades.size).to eq(2) + expect(shades).to include("Shading Surface 4") + expect(walls.size).to eq(1) + expect(ceilings.size).to eq(0) + expect(roofs.size).to eq(1) + next + elsif size == 3 + expect(time).to eq(1) + expect(shades.size).to eq(1) + expect(shades).to_not include("Shading Surface 4") + expect(walls.size).to eq(1) + expect(ceilings.size).to eq(0) + expect(roofs.size).to eq(1) + else + expect(size).to eq(2) + expect(shades.size).to eq(0) + expect(walls.size).to eq(1) + expect(ceilings.size).to eq(0) + expect(roofs.size).to eq(1) + end + else + expect(time).to eq(1) + expect(type).to eq(:ceiling) + expect(size).to eq(4) + expect(shades.size).to eq(0) + expect(walls.size).to eq(2) + expect(ceilings.size).to eq(2) + expect(pceilings.size).to eq(1) + expect(roofs.size).to eq(0) + end + end end + end + + it "can take in custom (expansion) joints as thermal bridges" do + translator = OpenStudio::OSVersion::VersionTranslator.new + TBD.clean! + + 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 + + # TBD will automatically tag as a (mild) "transition" any shared edge + # between 2x linked walls that +/- share the same 3D plane. An edge shared + # between 2x roof surfaces will equally be tagged as a "transition" edge. + # + # By default, transition edges are set @0 W/K.m i.e., no derating occurs. + # Although structural expansion joints or roof curbs are not as commonly + # encountered as mild transitions, they do constitute significant thermal + # bridges (to consider). Unfortunately, "joints" remain undistinguishable + # from transition edges when parsing OpenStudio geometry. The test here + # illustrates how users can override default "transition" tags via JSON + # input files. + # + # The "tbd_warehouse6.json" file identifies 2x edges in the US DOE + # warehouse prototype building that TBD tags as (mild) transitions by + # default. Both edges concern the "Fine Storage" space (likely as a means + # to ensure surface convexity in the EnergyPlus model). The "ok" PSI set + # holds a single "joint" PSI value of 0.9 W/K per metre (let's assume both + # edges are significant expansion joints, rather than modelling artifacts). + # Each "expansion joint" here represents 4.27m x 0.9 W/K.m (== 3.84 W/K). + # As wall constructions are the same for all 4x walls concerned, each wall + # inherits 1/2 of the extra heat loss from each joint, i.e. 1.92 W/K. + # + # "psis": [ + # { + # "id": "ok", + # "joint": 0.9 + # } + # ], + # "edges": [ + # { + # "psi": "ok", + # "type": "joint", + # "surfaces": [ + # "Fine Storage Front Wall", + # "Fine Storage Office Front Wall" + # ] + # }, + # { + # "psi": "ok", + # "type": "joint", + # "surfaces": [ + # "Fine Storage Left Wall", + # "Fine Storage Office Left Wall" + # ] + # } + # ] + # } - expect(derated.size).to eq(ids.size) - expect(doors.size).to eq(2) + argh = {} + argh[:option ] = "poor (BETBG)" + argh[:io_path ] = File.join(__dir__, "../json/tbd_warehouse6.json") + argh[:schema_path] = File.join(__dir__, "../tbd.schema.json") - # Side-testing glass door detection. - doors.each do |door| - expect(door).to have_key(:u) - expect(door).to have_key(:glazed) - expect(door[:glazed]).to be true - expect(door[:u]).to be_a(Numeric) - expect(door[:u]).to be_within(TOL).of(6.54) - end + 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.status).to be_zero + expect(TBD.logs).to be_empty + expect(surfaces).to be_a(Hash) + expect(surfaces.size).to eq(23) + expect(io).to be_a(Hash) + expect(io).to have_key(:edges) + expect(io[:edges].size).to eq(300) - # Testing plenum/attic surfaces. - plnum_floors = [] - derated_floors = [] + ids = { a: "Office Front Wall", + b: "Office Left Wall", + c: "Fine Storage Roof", + d: "Fine Storage Office Front Wall", + e: "Fine Storage Office Left Wall", + f: "Fine Storage Front Wall", + g: "Fine Storage Left Wall", + h: "Fine Storage Right Wall", + i: "Bulk Storage Roof", + j: "Bulk Storage Rear Wall", + k: "Bulk Storage Left Wall", + l: "Bulk Storage Right Wall" }.freeze + # Testing. surfaces.each do |id, surface| - expect(surface).to have_key(:space) - next unless surface[:space] == plnum - next unless surface[:type ] == :floor - - expect(derated).to_not include(id) - expect(flr_ids).to include(id) - plnum_floors << id - next unless surface.key?(:heatloss) - - derated_floors << id if surface.key?(:heatloss) - end - - # None are derated, i.e. plenum more akin to an UNCONDITIONED attic. - expect(plnum_floors.size).to eq(5) - expect(derated_floors).to be_empty - - # Plenum floors are not derated, yet the adjacent ceiling below should be. - derated_ceilings = [] - - plnum_floors.each do |id| - expect(surfaces[id]).to have_key(:boundary) - b = surfaces[id][:boundary] - - expect(surfaces).to have_key(b) - expect(surfaces[b]).to have_key(:heatloss) - expect(surfaces[b]).to have_key(:conditioned) - expect(surfaces[b]).to have_key(:space) - expect(surfaces[b][:conditioned]).to be true - expect(surfaces[b][:space]).to_not eq(plnum) - - expect(ids).to_not include(id) - next if id == "C5-1P" # core space ceiling - - expect(surfaces[b]).to have_key(:ratio) - h = surfaces[b][:heatloss] - expect(h).to be_within(TOL).of(5.79) if id == "C1-1P" - expect(h).to be_within(TOL).of(2.89) if id == "C2-1P" - expect(h).to be_within(TOL).of(5.79) if id == "C3-1P" - expect(h).to be_within(TOL).of(2.89) if id == "C4-1P" - - derated_ceilings << id + expect(ids).to_not have_value(id) unless surface.key?(:edges) end - expect(derated_ceilings.size).to eq(4) - surfaces.each do |id, surface| next unless surface.key?(:edges) expect(ids).to have_value(id) - expect(surface).to have_key(:heatloss) - next if id == ids[:i] - expect(surface).to have_key(:ratio) + expect(surface).to have_key(:heatloss) h = surface[:heatloss] s = model.getSurfaceByName(id) expect(s).to_not be_empty @@ -6353,1225 +5953,1408 @@ expect(s.nameString).to eq(id) expect(s.isConstructionDefaulted).to be false expect(s.construction.get.nameString).to include(" tbd") - expect(h).to be_within(TOL).of( 0.00) if id == "C5-1" - expect(h).to be_within(TOL).of(64.92) if id == "FRONT-1" + expect(h).to be_within(TOL).of( 50.20) if id == ids[:a] + expect(h).to be_within(TOL).of( 24.06) if id == ids[:b] + expect(h).to be_within(TOL).of( 87.16) if id == ids[:c] + expect(h).to be_within(TOL).of( 24.53) if id == ids[:d] # 22.61 + 1.92 + expect(h).to be_within(TOL).of( 11.07) if id == ids[:e] # 9.15 + 1.92 + expect(h).to be_within(TOL).of( 28.39) if id == ids[:f] # 26.47 + 1.92 + expect(h).to be_within(TOL).of( 29.11) if id == ids[:g] # 27.19 + 1.92 + expect(h).to be_within(TOL).of( 41.36) if id == ids[:h] + expect(h).to be_within(TOL).of(161.02) if id == ids[:i] + expect(h).to be_within(TOL).of( 62.28) if id == ids[:j] + expect(h).to be_within(TOL).of(117.87) if id == ids[:k] + expect(h).to be_within(TOL).of( 95.77) if id == ids[:l] + + 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.layers[1].nameString).to include("m tbd") + end + + surfaces.each do |id, surface| + if surface.key?(:ratio) + # ratio = format "%3.1f", surface[:ratio] + # name = id.rjust(15, " ") + # puts "#{name} RSi derated by #{ratio}%" + expect(surface[:ratio]).to be_within(0.2).of(-44.13) if id == ids[:a] + expect(surface[:ratio]).to be_within(0.2).of(-53.02) if id == ids[:b] + expect(surface[:ratio]).to be_within(0.2).of(-15.60) if id == ids[:c] + expect(surface[:ratio]).to be_within(0.2).of(-26.10) if id == ids[:d] + expect(surface[:ratio]).to be_within(0.2).of(-30.86) if id == ids[:e] + expect(surface[:ratio]).to be_within(0.2).of(-21.26) if id == ids[:f] + expect(surface[:ratio]).to be_within(0.2).of(-20.65) if id == ids[:g] + expect(surface[:ratio]).to be_within(0.2).of(-20.51) if id == ids[:h] + expect(surface[:ratio]).to be_within(0.2).of( -7.29) if id == ids[:i] + expect(surface[:ratio]).to be_within(0.2).of(-14.93) if id == ids[:j] + expect(surface[:ratio]).to be_within(0.2).of(-19.02) if id == ids[:k] + expect(surface[:ratio]).to be_within(0.2).of(-15.09) if id == ids[:l] + else + expect(surface[:boundary]).to_not eq("outdoors") + end end end - it "can handle TDDs" do + it "can process seb2.osm (0 W/K per m)" do translator = OpenStudio::OSVersion::VersionTranslator.new - version = OpenStudio.openStudioVersion.split(".").join.to_i TBD.clean! - methods = OpenStudio::Model::Model.instance_methods - methods = methods.select { |m| m.to_s.downcase.include?("tubular") } - methods.map! { |m| m.to_s.downcase } - - types = OpenStudio::Model::SubSurface.validSubSurfaceTypeValues - expect(types).to include("TubularDaylightDome") - expect(types).to include("TubularDaylightDiffuser") - - file = File.join(__dir__, "files/osms/in/warehouse.osm") + file = File.join(__dir__, "files/osms/out/seb2.osm") path = OpenStudio::Path.new(file) model = translator.loadModel(path) expect(model).to_not be_empty model = model.get - # As of v3.3.0, OpenStudio SDK (fully) supports Tubular Daylighting Devices: - # - # https://bigladdersoftware.com/epx/docs/9-6/input-output-reference/ - # group-daylighting.html#daylightingdevicetubular - # - # https://openstudio-sdk-documentation.s3.amazonaws.com/cpp/ - # OpenStudio-3.3.0-doc/model/html/ - # classopenstudio_1_1model_1_1_daylighting_device_tubular.html - # - # For SDK versions >= v3.3.0, testing new TDD methods. - unless version < 330 - expect(methods).to_not be_empty - valid = methods.any? { |method| method.include?("tubular") } - expect(valid).to be true - - # Simple Glazing constructions for both dome & diffuser. - fen = OpenStudio::Model::Construction.new(model) - fen.setName("tubular_fen") - - glazing = OpenStudio::Model::SimpleGlazing.new(model) - glazing.setName("tubular_glazing") - expect(glazing.setUFactor( 6.00)).to be true - expect(glazing.setSolarHeatGainCoefficient(0.50)).to be true - expect(glazing.setVisibleTransmittance( 0.70)).to be true - - layers = OpenStudio::Model::MaterialVector.new - layers << glazing - expect(fen.setLayers(layers)).to be true - - # Tube walls. - construction = OpenStudio::Model::Construction.new(model) - construction.setName("tube_construction") - - interior = OpenStudio::Model::StandardOpaqueMaterial.new(model) - interior.setName("tube_wall") - expect(interior.setRoughness( "MediumRough")).to be true - expect(interior.setThickness( 0.0126)).to be true - expect(interior.setConductivity( 0.1600)).to be true - expect(interior.setDensity( 784.9000)).to be true - expect(interior.setSpecificHeat( 830.0000)).to be true - expect(interior.setThermalAbsorptance(0.9000)).to be true - expect(interior.setSolarAbsorptance( 0.9000)).to be true - expect(interior.setVisibleAbsorptance(0.9000)).to be true - - layers = OpenStudio::Model::MaterialVector.new - layers << interior - expect(construction.setLayers(layers)).to be true - - # Host spaces & surfaces. - sp1 = "Zone1 Office" - sp2 = "Zone2 Fine Storage" - z = "Zone2 Fine Storage ZN" - s1 = "Office Roof" # Office surface hosting new TDD diffuser - s2 = "Office Roof Reversed" # FineStorage floor, above office - s3 = "Fine Storage Roof" # FineStorage surface hosting new TDD dome - - # Fetch host spaces & surfaces. - office = model.getSpaceByName(sp1) - expect(office).to_not be_empty - office = office.get - - storage = model.getSpaceByName(sp2) - expect(storage).to_not be_empty - storage = storage.get - - zone = storage.thermalZone - expect(zone).to_not be_empty - zone = zone.get - expect(zone.nameString).to eq(z) - - ceiling = model.getSurfaceByName(s1) - expect(ceiling).to_not be_empty - ceiling = ceiling.get - - sp = ceiling.space - expect(sp).to_not be_empty - sp = sp.get - expect(sp).to eq(office) - - floor = model.getSurfaceByName(s2) - expect(floor).to_not be_empty - floor = floor.get + argh = { option: "(non thermal bridging)" } - sp = floor.space - expect(sp).to_not be_empty - sp = sp.get - expect(sp).to eq(storage) + 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.status).to be_zero + expect(TBD.logs).to be_empty + expect(surfaces).to be_a(Hash) + expect(surfaces.size).to eq(56) + expect(io).to be_a(Hash) + expect(io).to have_key(:edges) + expect(io[:edges].size).to eq(106) - adj = ceiling.adjacentSurface - expect(adj).to_not be_empty - adj = adj.get - expect(adj).to eq(floor) + surfaces.each do |id, surface| + expect(surface).to have_key(:conditioned) + next unless surface[:conditioned] - adj = floor.adjacentSurface - expect(adj).to_not be_empty - adj = adj.get - expect(adj).to eq(ceiling) + expect(surface).to have_key(:heating) + expect(surface).to have_key(:cooling) + end - roof = model.getSurfaceByName(s3) - expect(roof).to_not be_empty - roof = roof.get + # Since all PSI values = 0, we're not expecting any derated surfaces + surfaces.values.each { |surface| expect(surface).to_not have_key(:ratio) } + end - sp = roof.space - expect(sp).to_not be_empty - sp = sp.get - expect(sp).to eq(storage) + it "can process seb2.osm (0 W/K per m) with JSON" do + translator = OpenStudio::OSVersion::VersionTranslator.new + TBD.clean! - # Setting heights & Z-axis coordinates. - ceiling_Z = ceiling.centroid.z - roof_Z = roof.centroid.z - length = roof_Z - ceiling_Z - totalLength = length + 0.7 - dome_Z = ceiling_Z + totalLength + file = File.join(__dir__, "files/osms/out/seb2.osm") + path = OpenStudio::Path.new(file) + model = translator.loadModel(path) + expect(model).to_not be_empty + model = model.get - # A new, 1mx1m diffuser subsurface in Office. - os_v = OpenStudio::Point3dVector.new - os_v << OpenStudio::Point3d.new( 11.0, 4.0, ceiling_Z) - os_v << OpenStudio::Point3d.new( 11.0, 5.0, ceiling_Z) - os_v << OpenStudio::Point3d.new( 10.0, 5.0, ceiling_Z) - os_v << OpenStudio::Point3d.new( 10.0, 4.0, ceiling_Z) + argh = {} + argh[:option ] = "(non thermal bridging)" + argh[:io_path ] = File.join(__dir__, "../json/tbd_seb.json") + argh[:schema_path] = File.join(__dir__, "../tbd.schema.json") - diffuser = OpenStudio::Model::SubSurface.new(os_v, model) - diffuser.setName("diffuser") - expect(diffuser.setConstruction(fen)).to be true - expect(diffuser.setSubSurfaceType("TubularDaylightDiffuser")).to be true - expect(diffuser.setSurface(ceiling)).to be true - expect(diffuser.uFactor).to_not be_empty - expect(diffuser.uFactor.get).to be_within(0.1).of(6.0) + 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.status).to be_zero + expect(TBD.logs).to be_empty + expect(surfaces).to be_a(Hash) + expect(surfaces.size).to eq(56) + expect(io).to be_a(Hash) + expect(io).to have_key(:edges) + expect(io[:edges].size).to eq(106) - # A new, 1mx1m dome subsurface above Fine Storage roof. - os_v = OpenStudio::Point3dVector.new - os_v << OpenStudio::Point3d.new( 11.0, 4.0, dome_Z) - os_v << OpenStudio::Point3d.new( 11.0, 5.0, dome_Z) - os_v << OpenStudio::Point3d.new( 10.0, 5.0, dome_Z) - os_v << OpenStudio::Point3d.new( 10.0, 4.0, dome_Z) + # As the :building PSI set on file remains "(non thermal bridging)", one + # should not expect differences in results, i.e. derating shouldn't occur. + surfaces.values.each { |surface| expect(surface).to_not have_key(:ratio) } + end - dome = OpenStudio::Model::SubSurface.new(os_v, model) - dome.setName("dome") - expect(dome.setConstruction(fen)).to be true - expect(dome.setSubSurfaceType("TubularDaylightDome")).to be true - expect(dome.setSurface(roof)).to be true - expect(dome.uFactor).to_not be_empty - expect(dome.uFactor.get).to be_within(0.1).of(6.0) + it "can process seb2.osm (0 W/K per m) with JSON (non-0)" do + translator = OpenStudio::OSVersion::VersionTranslator.new + TBD.clean! - expect(ceiling.tilt).to be_within(TOL).of(diffuser.tilt) - expect(dome.tilt ).to be_within(TOL).of( roof.tilt) + file = File.join(__dir__, "files/osms/out/seb2.osm") + path = OpenStudio::Path.new(file) + model = translator.loadModel(path) + expect(model).to_not be_empty + model = model.get - rsi = 0.28 # default effective TDD RSi (dome to diffuser) - diameter = Math.sqrt(dome.grossArea/Math::PI) * 2 + # Consider the plenum as UNCONDITIONED. + plnum = model.getSpaceByName("Level 0 Ceiling Plenum") + expect(plnum).to_not be_empty + plnum = plnum.get + expect(TBD.unconditioned?(plnum)).to be false - tdd = OpenStudio::Model::DaylightingDeviceTubular.new( - dome, diffuser, construction) + key = "space_conditioning_category" + val = "Unconditioned" + expect(plnum.additionalProperties.hasFeature(key)).to be false + expect(plnum.additionalProperties.setFeature(key, val)).to be true + expect(TBD.plenum?(plnum)).to be true # fyi, still has "plenum" spacetype + expect(TBD.unconditioned?(plnum)).to be true # ... more reliable + expect(TBD.setpoints(plnum)[:heating]).to be_nil + expect(TBD.setpoints(plnum)[:cooling]).to be_nil + expect(TBD.status).to be_zero - expect(tdd.setDiameter(diameter)).to be true - expect(tdd.setTotalLength(totalLength)).to be true - expect(tdd.addTransitionZone(zone, length)).to be true - cl = OpenStudio::Model::TransitionZoneVector - expect(tdd.transitionZones.class ).to eq(cl) - expect(tdd.numberofTransitionZones).to eq(1) + argh = {} + argh[:option ] = "(non thermal bridging)" + argh[:io_path ] = File.join(__dir__, "../json/tbd_seb_n0.json") + argh[:schema_path] = File.join(__dir__, "../tbd.schema.json") - expect(tdd.subSurfaceDome).to eq(dome) - expect(tdd.subSurfaceDiffuser).to eq(diffuser) + 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.status).to be_zero + expect(TBD.logs).to be_empty + expect(surfaces).to be_a(Hash) + expect(surfaces.size).to eq(56) + expect(io).to be_a(Hash) + expect(io).to have_key(:edges) + expect(io[:edges].size).to eq(80) # 106 if plenum were INDIRECTLYCONDITIONED - c = tdd.construction - expect(c.to_LayeredConstruction).to_not be_empty - c = c.to_LayeredConstruction.get + ids = { a: "Entryway Wall 4", + b: "Entryway Wall 5", + c: "Entryway Wall 6", + d: "Entry way DroppedCeiling", + e: "Utility1 Wall 1", + f: "Utility1 Wall 5", + g: "Utility 1 DroppedCeiling", + h: "Smalloffice 1 Wall 1", + i: "Smalloffice 1 Wall 2", + j: "Smalloffice 1 Wall 6", + k: "Small office 1 DroppedCeiling", + l: "Openarea 1 Wall 3", + m: "Openarea 1 Wall 4", + n: "Openarea 1 Wall 5", + o: "Openarea 1 Wall 6", + p: "Openarea 1 Wall 7", + q: "Open area 1 DroppedCeiling" + }.freeze - expect(c.nameString).to eq(construction.nameString) - expect(tdd.diameter).to be_within(TOL).of(diameter) - expect(tdd.effectiveThermalResistance).to be_within(TOL).of(rsi) + # The :building PSI set on file "compliant" supersedes the argh[:option] + # "(non thermal bridging)", so one should expect differences in results, + # i.e. derating should occur. The next 2 tests: + # 1. setting both argh[:option] & file :building to "compliant" + # 2. setting argh[:option] to "compliant" + removing :building from file + # ... all 3x cases should yield the same results. + surfaces.each do |id, surface| + expect(ids).to have_value(id) if surface.key?(:edges) + end - pth = File.join(__dir__, "files/osms/out/tdd_warehouse.osm") - model.save(pth, true) + surfaces.each do |id, surface| + next unless surface.key?(:edges) - # Testing if TBD recognizes the TDD as a "skylight" (for derating & UA'). - argh = { option: "poor (BETBG)" } + expect(ids).to have_value(id) + expect(surface).to have_key(:heatloss) + expect(surface).to have_key(:ratio) + h = surface[:heatloss] + s = model.getSurfaceByName(id) + expect(s).to_not be_empty + s = s.get + expect(s.nameString).to eq(id) + expect(s.isConstructionDefaulted).to be false + expect(s.construction.get.nameString).to include(" tbd") + expect(h).to be_within(TOL).of( 3.62) if id == ids[:a] + expect(h).to be_within(TOL).of( 6.28) if id == ids[:b] + expect(h).to be_within(TOL).of( 2.62) if id == ids[:c] + expect(h).to be_within(TOL).of( 0.17) if id == ids[:d] + expect(h).to be_within(TOL).of( 7.13) if id == ids[:e] + expect(h).to be_within(TOL).of( 7.09) if id == ids[:f] + expect(h).to be_within(TOL).of( 0.20) if id == ids[:g] + expect(h).to be_within(TOL).of( 7.94) if id == ids[:h] + expect(h).to be_within(TOL).of( 5.17) if id == ids[:i] + expect(h).to be_within(TOL).of( 5.01) if id == ids[:j] + expect(h).to be_within(TOL).of( 0.22) if id == ids[:k] + expect(h).to be_within(TOL).of( 2.47) if id == ids[:l] + expect(h).to be_within(TOL).of( 3.11) if id == ids[:m] + expect(h).to be_within(TOL).of( 4.43) if id == ids[:n] + expect(h).to be_within(TOL).of( 3.35) if id == ids[:o] + expect(h).to be_within(TOL).of( 2.12) if id == ids[:p] + expect(h).to be_within(TOL).of( 0.31) if id == ids[:q] - 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.status.zero?).to be(true) - expect(TBD.logs).to be_empty - expect(surfaces).to be_a(Hash) - expect(surfaces.size).to eq(23) - expect(io).to be_a(Hash) - expect(io).to have_key(:edges) - expect(io[:edges].size).to eq(304) + c = s.construction + expect(c).to_not be_empty + c = c.get.to_LayeredConstruction + expect(c).to_not be_empty + c = c.get + i = 0 + i = 2 if s.outsideBoundaryCondition.downcase == "outdoors" + expect(c.layers[i].nameString).to include("m tbd") + end - # Both diffuser and parent (office) ceiling are stored as TBD 'surfaces'. - expect(surfaces).to have_key(s1) - surface = surfaces[s1] - expect(surface).to have_key(:skylights) - expect(surface[:skylights].size).to eq(1) - expect(surface[:skylights]).to have_key("diffuser") + surfaces.each do |id, surface| + expect(surface).to have_key(:filmRSI) - skylight = surface[:skylights]["diffuser"] - expect(skylight).to be_a(Hash) - expect(skylight).to have_key(:u) - expect(skylight[:u]).to be_a(Numeric) - expect(skylight[:u]).to be_within(TOL).of(1/rsi) - # ... yet TBD only derates constructions of opaque surfaces in CONDITIONED - # spaces if: - # - # (i) facing outdoors or - # (ii) facing UNCONDITIONED spaces like attics (see psi.rb). - # - # Here, the ceiling is not tagged by TBD as a deratable surface. - # Diffuser edges are therefore not logged in TBD's 'edges'. - expect(surface).to_not have_key(:heatloss) - expect(surface).to_not have_key(:ratio) + if surface.key?(:ratio) + expect(surface[:ratio]).to be_within(0.1).of(-28.93) if id == ids[:a] + expect(surface[:ratio]).to be_within(0.1).of(-26.61) if id == ids[:b] + expect(surface[:ratio]).to be_within(0.1).of(-25.82) if id == ids[:c] + expect(surface[:ratio]).to be_within(0.1).of( -0.06) if id == ids[:d] + expect(surface[:ratio]).to be_within(0.1).of(-27.14) if id == ids[:e] + expect(surface[:ratio]).to be_within(0.1).of(-27.18) if id == ids[:f] + expect(surface[:ratio]).to be_within(0.1).of( -0.05) if id == ids[:g] + expect(surface[:ratio]).to be_within(0.1).of(-32.40) if id == ids[:h] + expect(surface[:ratio]).to be_within(0.1).of(-32.58) if id == ids[:i] + expect(surface[:ratio]).to be_within(0.1).of(-32.77) if id == ids[:j] + expect(surface[:ratio]).to be_within(0.1).of( -0.05) if id == ids[:k] + expect(surface[:ratio]).to be_within(0.1).of(-18.14) if id == ids[:l] + expect(surface[:ratio]).to be_within(0.1).of(-21.97) if id == ids[:m] + expect(surface[:ratio]).to be_within(0.1).of(-18.77) if id == ids[:n] + expect(surface[:ratio]).to be_within(0.1).of(-21.14) if id == ids[:o] + expect(surface[:ratio]).to be_within(0.1).of(-19.10) if id == ids[:p] + expect(surface[:ratio]).to be_within(0.1).of( -0.04) if id == ids[:q] - # Only edges of the dome (linked to the Fine Storage roof) are stored. - io[:edges].each do |edge| - expect(edge).to be_a(Hash) - expect(edge).to have_key(:surfaces) - expect(edge[:surfaces]).to be_a(Array) + next unless id == ids[:a] - edge[:surfaces].each do |id| - expect(id).to eq("dome") if ["dome", "diffuser"].include?(id) + s = model.getSurfaceByName(id) + expect(s).to_not be_empty + s = s.get + expect(s.nameString).to eq(id) + expect(s.surfaceType).to eq("Wall") + expect(s.isConstructionDefaulted).to be false + c = s.construction.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) + expect(c.layers[2].nameString).to include("m tbd") + expect(c.layers[2].to_StandardOpaqueMaterial).to_not be_empty + m = c.layers[2].to_StandardOpaqueMaterial.get + + initial_R = surface[:filmRSI] + 2.4674 + derated_R = surface[:filmRSI] + 0.9931 + derated_R += m.thickness / m.thermalConductivity + + ratio = -(initial_R - derated_R) * 100 / initial_R + expect(ratio).to be_within(1).of(surfaces[id][:ratio]) + else + if surface[:boundary] == "outdoors" + expect(surface[:conditioned]).to be false end end + end + end - expect(surfaces).to have_key(s3) - surface = surfaces[s3] - - expect(surface).to have_key(:skylights) - expect(surface[:skylights].size).to eq(15) # original 14x +1 - expect(surface[:skylights]).to have_key("dome") + it "can process seb2.osm (0 W/K per m) with JSON (non-0) 2" do + translator = OpenStudio::OSVersion::VersionTranslator.new + TBD.clean! - surface[:skylights].each do |i, skylight| - expect(skylight).to have_key(:u) - expect(skylight[:u]).to be_a(Numeric) - expect(skylight[:u]).to be_within(TOL).of(6.64) unless i == "dome" - expect(skylight[:u]).to be_within(TOL).of(1/rsi) if i == "dome" - end + file = File.join(__dir__, "files/osms/out/seb2.osm") + path = OpenStudio::Path.new(file) + model = translator.loadModel(path) + expect(model).to_not be_empty + model = model.get - expect(surface).to have_key(:heatloss) - expect(surface[:heatloss]).to be_within(TOL).of(89.16) # +2.0 W/K - expect(io[:edges].size).to eq(304) # 4x extra edges for dome only + # Consider the plenum as UNCONDITIONED. + plnum = model.getSpaceByName("Level 0 Ceiling Plenum") + expect(plnum).to_not be_empty + plnum = plnum.get + expect(TBD.unconditioned?(plnum)).to be false - out = JSON.pretty_generate(io) - outP = File.join(__dir__, "../json/tbd_warehouse15.out.json") - File.open(outP, "w") { |outP| outP.puts out } + key = "space_conditioning_category" + val = "Unconditioned" + expect(plnum.additionalProperties.hasFeature(key)).to be false + expect(plnum.additionalProperties.setFeature(key, val)).to be true + expect(TBD.plenum?(plnum)).to be true + expect(TBD.unconditioned?(plnum)).to be true + expect(TBD.setpoints(plnum)[:heating]).to be_nil + expect(TBD.setpoints(plnum)[:cooling]).to be_nil + expect(TBD.status).to be_zero - # Re-use the exported file as input for another warehouse. - model2 = translator.loadModel(pth) - expect(model2).to_not be_empty - model2 = model2.get + # Setting both PSI option & file :building to "compliant" + argh = {} + argh[:option ] = "compliant" # instead of "(non thermal bridging)" + argh[:io_path ] = File.join(__dir__, "../json/tbd_seb_n0.json") + argh[:schema_path] = File.join(__dir__, "../tbd.schema.json") - argh[:schema_path] = File.join(__dir__, "../tbd.schema.json") - argh[:io_path ] = File.join(__dir__, "../json/tbd_warehouse15.out.json") + 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.status).to be_zero + expect(TBD.logs).to be_empty + expect(surfaces).to be_a(Hash) + expect(surfaces.size).to eq(56) + expect(io).to be_a(Hash) + expect(io).to have_key(:edges) + expect(io[:edges].size).to eq(80) # 106 if plnum INDIRECTLYCONDITIONED - json2 = TBD.process(model2, argh) - expect(json2).to be_a(Hash) - expect(json2).to have_key(:io) - expect(json2).to have_key(:surfaces) - io2 = json2[:io ] - surfaces = json2[:surfaces] - expect(TBD.status).to be_zero - expect(TBD.logs).to be_empty - expect(surfaces).to be_a(Hash) - expect(surfaces.size).to eq(23) - expect(io).to be_a(Hash) - expect(io).to have_key(:edges) - expect(io[:edges].size).to eq(304) + ids = { a: "Entryway Wall 4", + b: "Entryway Wall 5", + c: "Entryway Wall 6", + d: "Entry way DroppedCeiling", + e: "Utility1 Wall 1", + f: "Utility1 Wall 5", + g: "Utility 1 DroppedCeiling", + h: "Smalloffice 1 Wall 1", + i: "Smalloffice 1 Wall 2", + j: "Smalloffice 1 Wall 6", + k: "Small office 1 DroppedCeiling", + l: "Openarea 1 Wall 3", + m: "Openarea 1 Wall 4", + n: "Openarea 1 Wall 5", + o: "Openarea 1 Wall 6", + p: "Openarea 1 Wall 7", + q: "Open area 1 DroppedCeiling" + }.freeze - # Now mimic (again) the export functionality of the measure. Both output - # files should be the same. - out2 = JSON.pretty_generate(io2) - outP2 = File.join(__dir__, "../json/tbd_warehouse16.out.json") - File.open(outP2, "w") { |outP2| outP2.puts out2 } - expect(FileUtils).to be_identical(outP, outP2) - else - expect(methods).to be_empty + surfaces.each do |id, surface| + expect(ids).to_not have_value(id) unless surface.key?(:edges) + end - # SDK pre-v3.3.0 testing on one of the existing skylights, as a tubular - # TDD dome (without a complete TDD object). - nom = "FineStorage_skylight_5" - sky5 = model.getSubSurfaceByName(nom) - expect(sky5).to_not be_empty - sky5 = sky5.get - expect(sky5.subSurfaceType.downcase).to eq("skylight") - name = "U 1.17 SHGC 0.39 Simple Glazing Skylight U-1.17 SHGC 0.39 2" + surfaces.each do |id, surface| + next unless surface.key?(:edges) - skylight = sky5.construction - expect(skylight).to_not be_empty - expect(skylight.get.nameString).to eq(name) + expect(ids).to have_value(id) + expect(surface).to have_key(:heatloss) + expect(surface).to have_key(:ratio) + h = surface[:heatloss] + s = model.getSurfaceByName(id) + expect(s).to_not be_empty + s = s.get + expect(s.nameString).to eq(id) + expect(s.isConstructionDefaulted).to be false + expect(s.construction.get.nameString).to include(" tbd") + expect(h).to be_within(TOL).of( 3.62) if id == ids[:a] + expect(h).to be_within(TOL).of( 6.28) if id == ids[:b] + expect(h).to be_within(TOL).of( 2.62) if id == ids[:c] + expect(h).to be_within(TOL).of( 0.17) if id == ids[:d] + expect(h).to be_within(TOL).of( 7.13) if id == ids[:e] + expect(h).to be_within(TOL).of( 7.09) if id == ids[:f] + expect(h).to be_within(TOL).of( 0.20) if id == ids[:g] + expect(h).to be_within(TOL).of( 7.94) if id == ids[:h] + expect(h).to be_within(TOL).of( 5.17) if id == ids[:i] + expect(h).to be_within(TOL).of( 5.01) if id == ids[:j] + expect(h).to be_within(TOL).of( 0.22) if id == ids[:k] + expect(h).to be_within(TOL).of( 2.47) if id == ids[:l] + expect(h).to be_within(TOL).of( 3.11) if id == ids[:m] + expect(h).to be_within(TOL).of( 4.43) if id == ids[:n] + expect(h).to be_within(TOL).of( 3.35) if id == ids[:o] + expect(h).to be_within(TOL).of( 2.12) if id == ids[:p] + expect(h).to be_within(TOL).of( 0.31) if id == ids[:q] - expect(sky5.setSubSurfaceType("TubularDaylightDome")).to be true - skylight = sky5.construction - expect(skylight).to_not be_empty - expect(skylight.get.nameString).to eq("Typical Interior Window") - # Weird to see "Typical Interior Window" as a suitable construction for a - # tubular skylight dome, but that's the assigned default construction in - # the DOE prototype warehouse model. + c = s.construction + expect(c).to_not be_empty + c = c.get.to_LayeredConstruction + expect(c).to_not be_empty + c = c.get + i = 0 + i = 2 if s.outsideBoundaryCondition.downcase == "outdoors" + expect(c.layers[i].nameString).to include("m tbd") + end - roof = model.getSurfaceByName("Fine Storage Roof") - expect(roof).to_not be_empty - roof = roof.get + surfaces.each do |id, surface| + expect(surface).to have_key(:filmRSI) - # Testing if TBD recognizes it as a "skylight" (for derating & UA'). - argh = { option: "poor (BETBG)" } + if surface.key?(:ratio) + expect(surface[:ratio]).to be_within(0.1).of(-28.93) if id == ids[:a] + expect(surface[:ratio]).to be_within(0.1).of(-26.61) if id == ids[:b] + expect(surface[:ratio]).to be_within(0.1).of(-25.82) if id == ids[:c] + expect(surface[:ratio]).to be_within(0.1).of( -0.06) if id == ids[:d] + expect(surface[:ratio]).to be_within(0.1).of(-27.14) if id == ids[:e] + expect(surface[:ratio]).to be_within(0.1).of(-27.18) if id == ids[:f] + expect(surface[:ratio]).to be_within(0.1).of( -0.05) if id == ids[:g] + expect(surface[:ratio]).to be_within(0.1).of(-32.40) if id == ids[:h] + expect(surface[:ratio]).to be_within(0.1).of(-32.58) if id == ids[:i] + expect(surface[:ratio]).to be_within(0.1).of(-32.77) if id == ids[:j] + expect(surface[:ratio]).to be_within(0.1).of( -0.05) if id == ids[:k] + expect(surface[:ratio]).to be_within(0.1).of(-18.14) if id == ids[:l] + expect(surface[:ratio]).to be_within(0.1).of(-21.97) if id == ids[:m] + expect(surface[:ratio]).to be_within(0.1).of(-18.77) if id == ids[:n] + expect(surface[:ratio]).to be_within(0.1).of(-21.14) if id == ids[:o] + expect(surface[:ratio]).to be_within(0.1).of(-19.10) if id == ids[:p] + expect(surface[:ratio]).to be_within(0.1).of( -0.04) if id == ids[:q] - 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.status).to be_zero - expect(TBD.logs).to be_empty - expect(surfaces).to be_a(Hash) - expect(surfaces.size).to eq(23) - expect(io).to be_a(Hash) - expect(io).to have_key(:edges) - expect(io[:edges].size).to eq(300) + next unless id == ids[:a] - expect(surfaces).to have_key("Fine Storage Roof") - surface = surfaces["Fine Storage Roof"] + s = model.getSurfaceByName(id) + expect(s).to_not be_empty + s = s.get + expect(s.nameString).to eq(id) + expect(s.surfaceType).to eq("Wall") + expect(s.isConstructionDefaulted).to be false + c = s.construction.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) + expect(c.layers[2].nameString).to include("m tbd") + expect(c.layers[2].to_StandardOpaqueMaterial).to_not be_empty + m = c.layers[2].to_StandardOpaqueMaterial.get - if surface.key?(:skylights) - expect(surface[:skylights]).to have_key(nom) + initial_R = surface[:filmRSI] + 2.4674 + derated_R = surface[:filmRSI] + 0.9931 + derated_R += m.thickness / m.thermalConductivity - surface[:skylights].each do |i, skylight| - expect(skylight).to have_key(:u) - expect(skylight[:u]).to be_a(Numeric) - expect(skylight[:u]).to be_within(TOL).of(6.64) unless i == nom - expect(skylight[:u]).to be_within(TOL).of(7.18) if i == nom - # So TBD processes any subsurface perimeter, whether skylight, TDD, - # etc. And it retrieves a calculated U-factor for TBD's UA' trade-off - # calculations. A follow-up OpenStudio-launched EnergyPlus simulation - # reveals that, despite having an incomplete TDD setup: - # - # dome > tube > diffuser - # - # ... EnergyPlus will proceed without warning(s) for OpenStudio - # < v3.3.0. Results reflect an expected increase in heating energy - # (Climate Zone 7), due to the poor(er) performance of the dome. + ratio = -(initial_R - derated_R) * 100 / initial_R + expect(ratio).to be_within(1).of(surfaces[id][:ratio]) + else + if surface[:boundary] == "outdoors" + expect(surface[:conditioned]).to be false end end end end - it "can handle TDDs in attics (false plenums)" do + it "can process seb2.osm (0 W/K per m) with JSON (non-0) 3" do translator = OpenStudio::OSVersion::VersionTranslator.new - version = OpenStudio.openStudioVersion.split(".").join.to_i TBD.clean! - file = File.join(__dir__, "files/osms/in/5Zone_2.osm") + file = File.join(__dir__, "files/osms/out/seb2.osm") path = OpenStudio::Path.new(file) model = translator.loadModel(path) expect(model).to_not be_empty model = model.get - # For SDK versions >= v3.3.0, testing new DaylightingTubularDevice methods. - unless version < 330 - # Both dome & diffuser: Simple Glazing constructions. - fen = OpenStudio::Model::Construction.new(model) - fen.setName("tubular_fen") - expect(fen.nameString).to eq("tubular_fen") - expect(fen.layers).to be_empty - - glazing = OpenStudio::Model::SimpleGlazing.new(model) - glazing.setName("tubular_glazing") - expect(glazing.nameString).to eq("tubular_glazing") - expect(glazing.setUFactor(6.0)).to be true - expect(glazing.setSolarHeatGainCoefficient(0.50)).to be true - expect(glazing.setVisibleTransmittance(0.70)).to be true - - layers = OpenStudio::Model::MaterialVector.new - layers << glazing - expect(fen.setLayers(layers)).to be true - expect(fen.layers.size).to eq(1) - expect(fen.layers[0].handle.to_s).to eq(glazing.handle.to_s) - expect(fen.uFactor).to_not be_empty - expect(fen.uFactor.get).to be_within(0.1).of(6.0) - - # Tube walls. - construction = OpenStudio::Model::Construction.new(model) - construction.setName("tube_construction") - expect(construction.nameString).to eq("tube_construction") - expect(construction.layers).to be_empty - - interior = OpenStudio::Model::StandardOpaqueMaterial.new(model) - interior.setName("tube_wall") - expect(interior.nameString).to eq("tube_wall") - expect(interior.setRoughness("MediumRough")).to be true - expect(interior.setThickness(0.0126)).to be true - expect(interior.setConductivity(0.16)).to be true - expect(interior.setDensity(784.9)).to be true - expect(interior.setSpecificHeat(830)).to be true - expect(interior.setThermalAbsorptance(0.9)).to be true - expect(interior.setSolarAbsorptance(0.9)).to be true - expect(interior.setVisibleAbsorptance(0.9)).to be true - - layers = OpenStudio::Model::MaterialVector.new - layers << interior - expect(construction.setLayers(layers)).to be true - expect(construction.layers.size).to eq(1) - expect(construction.layers[0].handle.to_s).to eq(interior.handle.to_s) - - # Host spaces & surfaces. - sp1 = "SPACE5-1" - sp2 = "PLENUM-1" - z = "PLENUM-1 Thermal Zone" - s1 = "C5-1" # sp1 surface hosting new TDD diffuser - s2 = "C5-1P" # plenum surface, above sp1 - s3 = "TOP-1" # plenum surface hosting new TDD dome - - # Fetch host spaces & surfaces. - space = model.getSpaceByName(sp1) - expect(space).to_not be_empty - space = space.get - - plenum = model.getSpaceByName(sp2) - expect(plenum).to_not be_empty - plenum = plenum.get - - zone = plenum.thermalZone - expect(zone).to_not be_empty - zone = zone.get - expect(zone.nameString).to eq(z) - - ceiling = model.getSurfaceByName(s1) - expect(ceiling).to_not be_empty - ceiling = ceiling.get - sp = ceiling.space - expect(sp).to_not be_empty - sp = sp.get - expect(sp).to eq(space) - - floor = model.getSurfaceByName(s2) - expect(floor).to_not be_empty - floor = floor.get - sp = floor.space - expect(sp).to_not be_empty - sp = sp.get - expect(sp).to eq(plenum) - - adj = ceiling.adjacentSurface - expect(adj).to_not be_empty - adj = adj.get - expect(adj).to eq(floor) - - adj = floor.adjacentSurface - expect(adj).to_not be_empty - adj = adj.get - expect(adj).to eq(ceiling) - - roof = model.getSurfaceByName(s3) - expect(roof).to_not be_empty - roof = roof.get - sp = roof.space - expect(sp).to_not be_empty - sp = sp.get - expect(sp).to eq(plenum) - - # Setting heights & Z-axis coordinates. - ceiling_Z = ceiling.centroid.z - roof_Z = roof.centroid.z - length = roof_Z - ceiling_Z - totalLength = length + 0.5 - dome_Z = ceiling_Z + totalLength - - # A new, 1mx1m diffuser subsurface in space ceiling. - os_v = OpenStudio::Point3dVector.new - os_v << OpenStudio::Point3d.new( 15.75, 7.15, ceiling_Z) - os_v << OpenStudio::Point3d.new( 15.75, 8.15, ceiling_Z) - os_v << OpenStudio::Point3d.new( 14.75, 8.15, ceiling_Z) - os_v << OpenStudio::Point3d.new( 14.75, 7.15, ceiling_Z) - diffuser = OpenStudio::Model::SubSurface.new(os_v, model) - diffuser.setName("diffuser") - expect(diffuser.setConstruction(fen)).to be true - expect(diffuser.setSubSurfaceType("TubularDaylightDiffuser")).to be true - expect(diffuser.setSurface(ceiling)).to be true - expect(diffuser.uFactor).to_not be_empty - expect(diffuser.uFactor.get).to be_within(0.1).of(6.0) - - # A new, 1mx1m dome subsurface above Plenum roof. - os_v = OpenStudio::Point3dVector.new - os_v << OpenStudio::Point3d.new( 15.75, 7.15, dome_Z) - os_v << OpenStudio::Point3d.new( 15.75, 8.15, dome_Z) - os_v << OpenStudio::Point3d.new( 14.75, 8.15, dome_Z) - os_v << OpenStudio::Point3d.new( 14.75, 7.15, dome_Z) - dome = OpenStudio::Model::SubSurface.new(os_v, model) - dome.setName("dome") - expect(dome.setConstruction(fen)).to be true - expect(dome.setSubSurfaceType("TubularDaylightDome")).to be true - expect(dome.setSurface(roof)).to be true - expect(dome.uFactor).to_not be_empty - expect(dome.uFactor.get).to be_within(0.1).of(6.0) - - expect(ceiling.tilt).to be_within(TOL).of(diffuser.tilt) - expect(dome.tilt).to be_within(TOL).of(roof.tilt) - - rsi = 0.28 - diameter = Math.sqrt(dome.grossArea/Math::PI) * 2 - - tdd = OpenStudio::Model::DaylightingDeviceTubular.new( - dome, diffuser, construction, diameter, totalLength, rsi) - - expect(tdd.addTransitionZone(zone, length)).to be true - cl = OpenStudio::Model::TransitionZoneVector - expect(tdd.transitionZones.class).to eq(cl) - expect(tdd.numberofTransitionZones).to eq(1) - expect(tdd.totalLength).to be_within(0.001).of(totalLength) + # Consider the plenum as UNCONDITIONED. + plnum = model.getSpaceByName("Level 0 Ceiling Plenum") + expect(plnum).to_not be_empty + plnum = plnum.get + expect(TBD.unconditioned?(plnum)).to be false - expect(tdd.subSurfaceDome).to eq(dome) - expect(tdd.subSurfaceDiffuser).to eq(diffuser) - c = tdd.construction - expect(c.to_LayeredConstruction).to_not be_empty - c = c.to_LayeredConstruction.get - expect(c.nameString).to eq(construction.nameString) - expect(tdd.diameter).to be_within(0.001).of(diameter) - expect(tdd.effectiveThermalResistance).to be_within(TOL).of(rsi) + key = "space_conditioning_category" + val = "Unconditioned" + expect(plnum.additionalProperties.hasFeature(key)).to be false + expect(plnum.additionalProperties.setFeature(key, val)).to be true + expect(TBD.plenum?(plnum)).to be true + expect(TBD.unconditioned?(plnum)).to be true + expect(TBD.setpoints(plnum)[:heating]).to be_nil + expect(TBD.setpoints(plnum)[:cooling]).to be_nil + expect(TBD.status).to be_zero - pth = File.join(__dir__, "files/osms/out/tdd_5Z_test.osm") - model.save(pth, true) + # Setting PSI set to "compliant" while removing the :building from file. + argh = {} + argh[:option ] = "compliant" # instead of "(non thermal bridging)" + argh[:io_path ] = File.join(__dir__, "../json/tbd_seb_n1.json") + argh[:schema_path] = File.join(__dir__, "../tbd.schema.json") - # Testing if TBD recognizes the TDD as a "skylight" (for derating & UA'). - argh = { option: "poor (BETBG)" } + 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.status).to be_zero + expect(TBD.logs).to be_empty + expect(surfaces).to be_a(Hash) + expect(surfaces.size).to eq(56) + expect(io).to be_a(Hash) + expect(io).to have_key(:edges) + expect(io[:edges].size).to eq(80) # 106 if plnum INDIRECTLYCONDITIONED - 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.status).to be_zero - expect(TBD.logs).to be_empty - expect(surfaces).to be_a(Hash) - expect(surfaces.size).to eq(40) - expect(io).to be_a(Hash) - expect(io).to have_key(:edges) - expect(io[:edges].size).to eq(51) # 4x extra edges for diffuser - not dome + ids = { a: "Entryway Wall 4", + b: "Entryway Wall 5", + c: "Entryway Wall 6", + d: "Entry way DroppedCeiling", + e: "Utility1 Wall 1", + f: "Utility1 Wall 5", + g: "Utility 1 DroppedCeiling", + h: "Smalloffice 1 Wall 1", + i: "Smalloffice 1 Wall 2", + j: "Smalloffice 1 Wall 6", + k: "Small office 1 DroppedCeiling", + l: "Openarea 1 Wall 3", + m: "Openarea 1 Wall 4", + n: "Openarea 1 Wall 5", + o: "Openarea 1 Wall 6", + p: "Openarea 1 Wall 7", + q: "Open area 1 DroppedCeiling" + }.freeze - # Both diffuser and parent ceiling are stored as TBD 'surfaces'. - expect(surfaces).to have_key(s1) - surface = surfaces[s1] - expect(surface).to have_key(:skylights) - expect(surface[:skylights].size).to eq(1) - expect(surface[:skylights]).to have_key("diffuser") - skylight = surface[:skylights]["diffuser"] - expect(skylight).to have_key(:u) - expect(skylight[:u]).to be_a(Numeric) - expect(skylight[:u]).to be_within(TOL).of(1/rsi) + surfaces.each do |id, surface| + expect(ids).to_not have_value(id) unless surface.key?(:edges) + end - # ... yet TBD only derates constructions of opaque surfaces in CONDITIONED - # spaces IF (i) facing outdoors or (ii) facing UNCONDITIONED spaces like - # attics (see psi.rb). Here, the ceiling is tagged by TBD as a deratable - # surface, and hence the diffuser edges are logged in TBD's 'edges'. + surfaces.each do |id, surface| + next unless surface.key?(:edges) + + expect(ids).to have_value(id) expect(surface).to have_key(:ratio) expect(surface).to have_key(:heatloss) - expect(surface[:heatloss]).to be_within(TOL).of(2.00) # 4x 0.500 W/K - - # Only edges of the diffuser (linked to the ceiling) are stored. - io[:edges].each do |edge| - expect(edge).to be_a(Hash) - expect(edge).to have_key(:surfaces) - expect(edge[:surfaces]).to be_a(Array) - - edge[:surfaces].each do |id| - expect(id).to eq("diffuser") if ["dome", "diffuser"].include?(id) - end - end - - expect(surfaces).to have_key(s3) - surface = surfaces[s3] + h = surface[:heatloss] + s = model.getSurfaceByName(id) + expect(s).to_not be_empty + s = s.get + expect(s.nameString).to eq(id) + expect(s.isConstructionDefaulted).to be false + expect(s.construction.get.nameString).to include(" tbd") + expect(h).to be_within(TOL).of( 3.62) if id == ids[:a] + expect(h).to be_within(TOL).of( 6.28) if id == ids[:b] + expect(h).to be_within(TOL).of( 2.62) if id == ids[:c] + expect(h).to be_within(TOL).of( 0.17) if id == ids[:d] + expect(h).to be_within(TOL).of( 7.13) if id == ids[:e] + expect(h).to be_within(TOL).of( 7.09) if id == ids[:f] + expect(h).to be_within(TOL).of( 0.20) if id == ids[:g] + expect(h).to be_within(TOL).of( 7.94) if id == ids[:h] + expect(h).to be_within(TOL).of( 5.17) if id == ids[:i] + expect(h).to be_within(TOL).of( 5.01) if id == ids[:j] + expect(h).to be_within(TOL).of( 0.22) if id == ids[:k] + expect(h).to be_within(TOL).of( 2.47) if id == ids[:l] + expect(h).to be_within(TOL).of( 3.11) if id == ids[:m] + expect(h).to be_within(TOL).of( 4.43) if id == ids[:n] + expect(h).to be_within(TOL).of( 3.35) if id == ids[:o] + expect(h).to be_within(TOL).of( 2.12) if id == ids[:p] + expect(h).to be_within(TOL).of( 0.31) if id == ids[:q] - expect(surface).to have_key(:skylights) - expect(surface[:skylights]).to_not be_nil - expect(surface[:skylights].size).to eq(1) - expect(surface[:skylights]).to have_key("dome") - skylight = surface[:skylights]["dome"] + c = s.construction + expect(c).to_not be_empty + c = c.get.to_LayeredConstruction + expect(c).to_not be_empty + c = c.get + i = 0 + i = 2 if s.outsideBoundaryCondition.downcase == "outdoors" + expect(c.layers[i].nameString).to include("m tbd") + end - expect(skylight).to have_key(:u) - expect(skylight[:u]).to be_a(Numeric) - expect(skylight[:u]).to be_within(TOL).of(1/rsi) - expect(surface).to_not have_key(:heatloss) - expect(surface).to_not have_key(:ratio) + surfaces.each do |id, surface| + expect(surface).to have_key(:filmRSI) - out = JSON.pretty_generate(io) - outP = File.join(__dir__, "../json/tbd_5Z.out.json") - File.open(outP, "w") { |outP| outP.puts out } + if surface.key?(:ratio) + # ratio = format "%3.1f", surface[:ratio] + # name = id.rjust(15, " ") + # puts "#{name} RSi derated by #{ratio}%" + expect(surface[:ratio]).to be_within(0.1).of(-28.93) if id == ids[:a] + expect(surface[:ratio]).to be_within(0.1).of(-26.61) if id == ids[:b] + expect(surface[:ratio]).to be_within(0.1).of(-25.82) if id == ids[:c] + expect(surface[:ratio]).to be_within(0.1).of( -0.06) if id == ids[:d] + expect(surface[:ratio]).to be_within(0.1).of(-27.14) if id == ids[:e] + expect(surface[:ratio]).to be_within(0.1).of(-27.18) if id == ids[:f] + expect(surface[:ratio]).to be_within(0.1).of( -0.05) if id == ids[:g] + expect(surface[:ratio]).to be_within(0.1).of(-32.40) if id == ids[:h] + expect(surface[:ratio]).to be_within(0.1).of(-32.58) if id == ids[:i] + expect(surface[:ratio]).to be_within(0.1).of(-32.77) if id == ids[:j] + expect(surface[:ratio]).to be_within(0.1).of( -0.05) if id == ids[:k] + expect(surface[:ratio]).to be_within(0.1).of(-18.14) if id == ids[:l] + expect(surface[:ratio]).to be_within(0.1).of(-21.97) if id == ids[:m] + expect(surface[:ratio]).to be_within(0.1).of(-18.77) if id == ids[:n] + expect(surface[:ratio]).to be_within(0.1).of(-21.14) if id == ids[:o] + expect(surface[:ratio]).to be_within(0.1).of(-19.10) if id == ids[:p] + expect(surface[:ratio]).to be_within(0.1).of( -0.04) if id == ids[:q] - # Re-use the exported file as input for another 5Z test. - model2 = translator.loadModel(pth) - expect(model2).to_not be_empty - model2 = model2.get + next unless id == ids[:a] - argh[:schema_path] = File.join(__dir__, "../tbd.schema.json") - argh[:io_path ] = File.join(__dir__, "../json/tbd_5Z.out.json") + s = model.getSurfaceByName(id) + expect(s).to_not be_empty + s = s.get + expect(s.nameString).to eq(id) + expect(s.surfaceType).to eq("Wall") + expect(s.isConstructionDefaulted).to be false + c = s.construction.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) + expect(c.layers[2].nameString).to include("m tbd") + expect(c.layers[2].to_StandardOpaqueMaterial).to_not be_empty + m = c.layers[2].to_StandardOpaqueMaterial.get - json2 = TBD.process(model2, argh) - expect(json2).to be_a(Hash) - expect(json2).to have_key(:io) - expect(json2).to have_key(:surfaces) - io2 = json2[:io ] - surfaces = json2[:surfaces] - expect(TBD.status).to be_zero - expect(TBD.logs).to be_empty - expect(surfaces).to be_a(Hash) - expect(surfaces.size).to eq(40) - expect(io2).to be_a(Hash) - expect(io2).to have_key(:edges) - expect(io2[:edges].size).to eq(51) + initial_R = surface[:filmRSI] + 2.4674 + derated_R = surface[:filmRSI] + 0.9931 + derated_R += m.thickness / m.thermalConductivity - # Now mimic (again) the export functionality of the measure. Both output - # files should be the same. - out2 = JSON.pretty_generate(io2) - outP2 = File.join(__dir__, "../json/tbd_5Z_2.out.json") - File.open(outP2, "w") { |outP2| outP2.puts out2 } - expect(FileUtils).to be_identical(outP, outP2) + ratio = -(initial_R - derated_R) * 100 / initial_R + expect(ratio).to be_within(1).of(surfaces[id][:ratio]) + else + if surface[:boundary] == "outdoors" + expect(surface[:conditioned]).to be false + end + end end end - it "can handle TDDs in attics" do + it "can process JSON surface KHI & PSI entries" do translator = OpenStudio::OSVersion::VersionTranslator.new - version = OpenStudio.openStudioVersion.split(".").join.to_i TBD.clean! - file = File.join(__dir__, "files/osms/in/smalloffice.osm") + file = File.join(__dir__, "files/osms/out/seb2.osm") path = OpenStudio::Path.new(file) model = translator.loadModel(path) expect(model).to_not be_empty model = model.get - # For SDK versions >= v3.3.0, testing new DaylightingTubularDevice methods. - unless version < 330 - # Both dome & diffuser: Simple Glazing constructions. - fen = OpenStudio::Model::Construction.new(model) - fen.setName("tubular_fen") - expect(fen.nameString).to eq("tubular_fen") - expect(fen.layers).to be_empty + # Consider the plenum as UNCONDITIONED. + plnum = model.getSpaceByName("Level 0 Ceiling Plenum") + expect(plnum).to_not be_empty + plnum = plnum.get + expect(TBD.unconditioned?(plnum)).to be false + + key = "space_conditioning_category" + val = "Unconditioned" + expect(plnum.additionalProperties.hasFeature(key)).to be false + expect(plnum.additionalProperties.setFeature(key, val)).to be true + expect(TBD.plenum?(plnum)).to be true + expect(TBD.unconditioned?(plnum)).to be true + expect(TBD.setpoints(plnum)[:heating]).to be_nil + expect(TBD.setpoints(plnum)[:cooling]).to be_nil + expect(TBD.status).to be_zero + + argh = {} + argh[:option ] = "(non thermal bridging)" # no :building PSI set on file + argh[:io_path ] = File.join(__dir__, "../json/tbd_seb_n3.json") + argh[:schema_path] = File.join(__dir__, "../tbd.schema.json") + + 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.status).to be_zero + expect(TBD.logs).to be_empty + expect(surfaces).to be_a(Hash) + expect(surfaces.size).to eq(56) + expect(io).to be_a(Hash) + expect(io).to have_key(:edges) + expect(io[:edges].size).to eq(80) # 106 if plnum INDIRECTLYCONDITIONED + + expect(io).to have_key(:building) # despite no being on file - good + expect(io[:building]).to have_key(:psi) + expect(io[:building][:psi]).to eq("(non thermal bridging)") + + # As the :building PSI set on file remains "(non thermal bridging)", one + # should not expect differences in results, i.e. derating shouldn't occur + # for most surfaces. However, the JSON file holds KHI entries for + # "Entryway Wall 5": + # 3x "columns" @0.5 W/K + 4x supports @0.5W/K = 3.5 W/K (as in case above), + # and a "good" PSI set (:parapet, of 0.5 W/K per m). + nom1 = "Entryway Wall 5" + nom2 = "Entry way DroppedCeiling" + + surfaces.each do |id, surface| + next unless surface.key?(:ratio) + + expect(id).to eq(nom1).or eq(nom2) + expect(surface[:heatloss]).to be_within(TOL).of(5.17) if id == nom1 + expect(surface[:heatloss]).to be_within(TOL).of(0.13) if id == nom2 + expect(surface).to have_key(:edges) + expect(surface[:edges].size).to eq(10) if id == nom1 + expect(surface[:edges].size).to eq( 6) if id == nom2 + end + + expect(io).to have_key(:edges) + expect(io[:edges].size).to eq(80) + + # The JSON input file (tbd_seb_n3.json) holds 2x PSI sets: + # - "good" for "Entryway Wall 5" + # - "compliant" (ignored) + # + # The PSI set "good" only holds non-zero PSI values for: + # - :rimjoist (there are none for "Entryway Wall 5") + # - :parapet (a single edge shared with "Entry way DroppedCeiling") + # + # Only those 2x surfaces will be derated. The following counters track the + # total number of edges delineating either derated surfaces that contribute + # in derating their insulation materials i.e. found in the "good" PSI set. + nb_rimjoist_edges = 0 + nb_parapet_edges = 0 + nb_fen_edges = 0 + nb_head_edges = 0 + nb_sill_edges = 0 + nb_jamb_edges = 0 + nb_corners = 0 + nb_concave_edges = 0 + nb_convex_edges = 0 + nb_balcony_edges = 0 + nb_party_edges = 0 + nb_grade_edges = 0 + nb_transition_edges = 0 + + io[:edges].each do |edge| + expect(edge).to have_key(:psi) + expect(edge).to have_key(:type) + expect(edge).to have_key(:length) + expect(edge).to have_key(:surfaces) + t = edge[:type] + s = {} + valid = edge[:surfaces].include?(nom1) || edge[:surfaces].include?(nom2) + next unless valid - glazing = OpenStudio::Model::SimpleGlazing.new(model) - glazing.setName("tubular_glazing") - expect(glazing.nameString).to eq("tubular_glazing") - expect(glazing.setUFactor(6.0)).to be true - expect(glazing.setSolarHeatGainCoefficient(0.50)).to be true - expect(glazing.setVisibleTransmittance(0.70)).to be true + io[:psis].each { |set| s = set if set[:id] == edge[:psi] } - layers = OpenStudio::Model::MaterialVector.new - layers << glazing - expect(fen.setLayers(layers)).to be true - expect(fen.layers.size).to eq(1) - expect(fen.layers[0].handle.to_s).to eq(glazing.handle.to_s) - expect(fen.uFactor).to_not be_empty - expect(fen.uFactor.get).to be_within(0.1).of(6.0) + next if s.empty? - # Tube walls. - construction = OpenStudio::Model::Construction.new(model) - construction.setName("tube_construction") - expect(construction.nameString).to eq("tube_construction") - expect(construction.layers).to be_empty + expect(s).to be_a(Hash) + nb_rimjoist_edges += 1 if t == :rimjoist + nb_rimjoist_edges += 1 if t == :rimjoistconcave + nb_rimjoist_edges += 1 if t == :rimjoistconvex + nb_parapet_edges += 1 if t == :parapet + nb_parapet_edges += 1 if t == :parapetconcave + nb_parapet_edges += 1 if t == :parapetconvex + nb_fen_edges += 1 if t == :fenestration + nb_head_edges += 1 if t == :head + nb_sill_edges += 1 if t == :sill + nb_jamb_edges += 1 if t == :jamb + nb_corners += 1 if t == :corner + nb_concave_edges += 1 if t == :cornerconcave + nb_convex_edges += 1 if t == :cornerconvex + nb_balcony_edges += 1 if t == :balcony + nb_party_edges += 1 if t == :party + nb_grade_edges += 1 if t == :grade + nb_grade_edges += 1 if t == :gradeconcave + nb_grade_edges += 1 if t == :gradeconvex + nb_transition_edges += 1 if t == :transition - interior = OpenStudio::Model::StandardOpaqueMaterial.new(model) - interior.setName("tube_wall") - expect(interior.nameString).to eq("tube_wall") - expect(interior.setRoughness("MediumRough")).to be true - expect(interior.setThickness(0.0126)).to be true - expect(interior.setConductivity(0.16)).to be true - expect(interior.setDensity(784.9)).to be true - expect(interior.setSpecificHeat(830)).to be true - expect(interior.setThermalAbsorptance(0.9)).to be true - expect(interior.setSolarAbsorptance(0.9)).to be true - expect(interior.setVisibleAbsorptance(0.9)).to be true + expect(t).to eq(:parapetconvex).or eq(:transition) + next unless t == :parapetconvex - layers = OpenStudio::Model::MaterialVector.new - layers << interior - expect(construction.setLayers(layers)).to be true - expect(construction.layers.size).to eq(1) - expect(construction.layers[0].handle.to_s).to eq(interior.handle.to_s) + expect(edge[:length]).to be_within(TOL).of(3.6) + end - # Host spaces & surfaces. - sp1 = "Core_ZN" - sp2 = "Attic" - z = "Attic ZN" - s1 = "Core_ZN_ceiling" # sp1 surface hosting new TDD diffuser - s2 = "Attic_floor_core" # attic surface, above sp1 - s3 = "Attic_roof_north" # attic surface hosting new TDD dome + expect(nb_rimjoist_edges ).to be_zero + expect(nb_parapet_edges ).to eq(1) # parapet linked to "good" PSI set + expect(nb_fen_edges ).to be_zero + expect(nb_head_edges ).to be_zero + expect(nb_sill_edges ).to be_zero + expect(nb_jamb_edges ).to be_zero + expect(nb_corners ).to be_zero + expect(nb_concave_edges ).to be_zero + expect(nb_convex_edges ).to be_zero + expect(nb_balcony_edges ).to be_zero + expect(nb_party_edges ).to be_zero + expect(nb_grade_edges ).to be_zero + expect(nb_transition_edges).to eq(2) # all PSI sets inherit :transitions - # Fetch host spaces & surfaces. - core = model.getSpaceByName(sp1) - expect(core).to_not be_empty - core = core.get + # Reset counters to track the total number of edges delineating either + # derated surfaces that DO NOT contribute in derating their insulation + # materials i.e. not found in the "good" PSI set. + nb_rimjoist_edges = 0 + nb_parapet_edges = 0 + nb_fen_edges = 0 + nb_head_edges = 0 + nb_sill_edges = 0 + nb_jamb_edges = 0 + nb_corners = 0 + nb_concave_edges = 0 + nb_convex_edges = 0 + nb_balcony_edges = 0 + nb_party_edges = 0 + nb_grade_edges = 0 + nb_transition_edges = 0 - attic = model.getSpaceByName(sp2) - expect(attic).to_not be_empty - attic = attic.get + io[:edges].each do |edge| + s = {} + valid = edge[:surfaces].include?(nom1) || edge[:surfaces].include?(nom2) + next unless valid - zone = attic.thermalZone - expect(zone).to_not be_empty - zone = zone.get - expect(zone.nameString).to eq(z) + io[:psis].each { |set| s = set if set[:id] == edge[:psi] } - ceiling = model.getSurfaceByName(s1) - expect(ceiling).to_not be_empty - ceiling = ceiling.get + next unless s.empty? + expect(edge[:psi]).to eq(argh[:option]) - sp = ceiling.space - expect(sp).to_not be_empty - sp = sp.get - expect(sp).to eq(core) + t = edge[:type] + nb_rimjoist_edges += 1 if t == :rimjoist + nb_rimjoist_edges += 1 if t == :rimjoistconcave + nb_rimjoist_edges += 1 if t == :rimjoistconvex + nb_parapet_edges += 1 if t == :parapet + nb_parapet_edges += 1 if t == :parapetconcave + nb_parapet_edges += 1 if t == :parapetconvex + nb_fen_edges += 1 if t == :fenestration + nb_head_edges += 1 if t == :head + nb_sill_edges += 1 if t == :sill + nb_jamb_edges += 1 if t == :jamb + nb_corners += 1 if t == :corner + nb_concave_edges += 1 if t == :cornerconcave + nb_convex_edges += 1 if t == :cornerconvex + nb_balcony_edges += 1 if t == :balcony + nb_party_edges += 1 if t == :party + nb_grade_edges += 1 if t == :grade + nb_grade_edges += 1 if t == :gradeconcave + nb_grade_edges += 1 if t == :gradeconvex + nb_transition_edges += 1 if t == :transition + end - floor = model.getSurfaceByName(s2) - expect(floor).to_not be_empty - floor = floor.get + expect(nb_rimjoist_edges ).to be_zero + expect(nb_parapet_edges ).to eq(2) # not linked to "good" PSI set + expect(nb_fen_edges ).to be_zero + expect(nb_head_edges ).to eq(1) + expect(nb_sill_edges ).to eq(1) + expect(nb_jamb_edges ).to eq(2) + expect(nb_corners ).to be_zero + expect(nb_concave_edges ).to be_zero + expect(nb_convex_edges ).to eq(2) # edges between walls 5 & 4 + expect(nb_balcony_edges ).to be_zero + expect(nb_party_edges ).to be_zero + expect(nb_grade_edges ).to eq(1) + expect(nb_transition_edges).to eq(3) # shared roof edges - sp = floor.space - expect(sp).to_not be_empty - sp = sp.get - expect(sp).to eq(attic) + # Reset counters again to track the total number of edges delineating either + # derated surfaces that DO NOT contribute in derating their insulation + # materials i.e., automatically set as :transitions in "good" PSI set. + nb_rimjoist_edges = 0 + nb_parapet_edges = 0 + nb_fen_edges = 0 + nb_head_edges = 0 + nb_sill_edges = 0 + nb_jamb_edges = 0 + nb_corners = 0 + nb_concave_edges = 0 + nb_convex_edges = 0 + nb_balcony_edges = 0 + nb_party_edges = 0 + nb_grade_edges = 0 + nb_transition_edges = 0 - adj = ceiling.adjacentSurface - expect(adj).to_not be_empty - adj = adj.get - expect(adj).to eq(floor) + io[:edges].each do |edge| + t = edge[:type] + s = {} + valid = edge[:surfaces].include?(nom1) || edge[:surfaces].include?(nom2) + next unless valid - adj = floor.adjacentSurface - expect(adj).to_not be_empty - adj = adj.get - expect(adj).to eq(ceiling) + io[:psis].each { |set| s = set if set[:id] == edge[:psi] } - roof = model.getSurfaceByName(s3) - expect(roof).to_not be_empty - roof = roof.get + next if s.empty? - sp = roof.space - expect(sp).to_not be_empty - sp = sp.get - expect(sp).to eq(attic) + expect(s).to be_a(Hash) + next if t.to_s.include?("parapet") - # Setting heights & Z-axis coordinates. - ceiling_Z = 3.05 - roof_Z = 5.51 - length = roof_Z - ceiling_Z - totalLength = length + 1.0 - dome_Z = ceiling_Z + totalLength + nb_rimjoist_edges += 1 if t == :rimjoist + nb_rimjoist_edges += 1 if t == :rimjoistconcave + nb_rimjoist_edges += 1 if t == :rimjoistconvex + nb_parapet_edges += 1 if t == :parapet + nb_parapet_edges += 1 if t == :parapetconcave + nb_parapet_edges += 1 if t == :parapetconvex + nb_fen_edges += 1 if t == :fenestration + nb_head_edges += 1 if t == :head + nb_sill_edges += 1 if t == :sill + nb_jamb_edges += 1 if t == :jamb + nb_corners += 1 if t == :corner + nb_concave_edges += 1 if t == :cornerconcave + nb_convex_edges += 1 if t == :cornerconvex + nb_balcony_edges += 1 if t == :balcony + nb_party_edges += 1 if t == :party + nb_grade_edges += 1 if t == :grade + nb_grade_edges += 1 if t == :gradeconcave + nb_grade_edges += 1 if t == :gradeconvex + nb_transition_edges += 1 if t == :transition + end - # A new, 1mx1m diffuser subsurface in Core ceiling. - os_v = OpenStudio::Point3dVector.new - os_v << OpenStudio::Point3d.new( 14.345, 10.845, ceiling_Z) - os_v << OpenStudio::Point3d.new( 14.345, 11.845, ceiling_Z) - os_v << OpenStudio::Point3d.new( 13.345, 11.845, ceiling_Z) - os_v << OpenStudio::Point3d.new( 13.345, 10.845, ceiling_Z) - diffuser = OpenStudio::Model::SubSurface.new(os_v, model) - diffuser.setName("diffuser") - expect(diffuser.setConstruction(fen)).to be true - expect(diffuser.setSubSurfaceType("TubularDaylightDiffuser")).to be true - expect(diffuser.setSurface(ceiling)).to be true - expect(diffuser.uFactor).to_not be_empty - expect(diffuser.uFactor.get).to be_within(0.1).of(6.0) + expect(nb_rimjoist_edges ).to be_zero + expect(nb_parapet_edges ).to be_zero + expect(nb_fen_edges ).to be_zero + expect(nb_head_edges ).to be_zero + expect(nb_jamb_edges ).to be_zero + expect(nb_sill_edges ).to be_zero + expect(nb_corners ).to be_zero + expect(nb_concave_edges ).to be_zero + expect(nb_convex_edges ).to be_zero + expect(nb_balcony_edges ).to be_zero + expect(nb_party_edges ).to be_zero + expect(nb_grade_edges ).to be_zero + expect(nb_transition_edges).to eq(2) # edges between walls 5 & 6 + end - # A new, 1mx1m dome subsurface above Attic roof. - os_v = OpenStudio::Point3dVector.new - os_v << OpenStudio::Point3d.new( 14.345, 10.845, dome_Z) - os_v << OpenStudio::Point3d.new( 14.345, 11.845, dome_Z) - os_v << OpenStudio::Point3d.new( 13.345, 11.845, dome_Z) - os_v << OpenStudio::Point3d.new( 13.345, 10.845, dome_Z) - dome = OpenStudio::Model::SubSurface.new(os_v, model) - dome.setName("dome") - expect(dome.setConstruction(fen)).to be true - expect(dome.setSubSurfaceType("TubularDaylightDome")).to be true - expect(dome.setSurface(roof)).to be true - expect(dome.uFactor).to_not be_empty - expect(dome.uFactor.get).to be_within(0.1).of(6.0) + it "can process JSON surface KHI & PSI + building & edge (2)" do + translator = OpenStudio::OSVersion::VersionTranslator.new + TBD.clean! - expect(ceiling.tilt).to be_within(TOL).of(diffuser.tilt) - expect(dome.tilt).to be_within(TOL).of(0.0) - expect(roof.tilt).to be_within(TOL).of(0.32) + file = File.join(__dir__, "files/osms/out/seb2.osm") + path = OpenStudio::Path.new(file) + model = translator.loadModel(path) + expect(model).to_not be_empty + model = model.get - rsi = 0.28 - diameter = Math.sqrt(dome.grossArea/Math::PI) * 2 + argh = {} + argh[:option ] = "(non thermal bridging)" + argh[:io_path ] = File.join(__dir__, "../json/tbd_seb_n5.json") + argh[:schema_path] = File.join(__dir__, "../tbd.schema.json") - tdd = OpenStudio::Model::DaylightingDeviceTubular.new( - dome, diffuser, construction, diameter, totalLength, rsi) + 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.status).to be_zero + expect(TBD.logs).to be_empty + expect(surfaces).to be_a(Hash) + expect(surfaces.size).to eq(56) + expect(io).to be_a(Hash) + expect(io).to have_key(:edges) + expect(io[:edges].size).to eq(106) - expect(tdd.addTransitionZone(zone, length)).to be true - cl = OpenStudio::Model::TransitionZoneVector - expect(tdd.transitionZones.class).to eq(cl) - expect(tdd.numberofTransitionZones).to eq(1) - expect(tdd.totalLength).to be_within(0.001).of(totalLength) + # As above, yet the KHI points are now set @0.5 W/K per m (instead of 0) + surfaces.each do |id, surface| + next unless surface[:boundary] == "outdoors" - expect(tdd.subSurfaceDome).to eq(dome) - expect(tdd.subSurfaceDiffuser).to eq(diffuser) - c = tdd.construction - expect(c.to_LayeredConstruction).to_not be_empty - c = c.to_LayeredConstruction.get - expect(c.nameString).to eq(construction.nameString) - expect(tdd.diameter).to be_within(0.001).of(diameter) - expect(tdd.effectiveThermalResistance).to be_within(TOL).of(rsi) + expect(surface).to_not have_key(:ratio) unless id == "Entryway Wall 5" + next unless id == "Entryway Wall 5" - pth = File.join(__dir__, "files/osms/out/tdd_smalloffice_test.osm") - model.save(pth, true) + expect(surface[:heatloss]).to be_within(TOL).of(12.39) + end + end - # Testing if TBD recognizes the TDD as a "skylight" (for derating & UA'). - argh = { option: "poor (BETBG)" } + it "can process JSON surface KHI & PSI + building & edge (3)" do + translator = OpenStudio::OSVersion::VersionTranslator.new + TBD.clean! - 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.status).to be_zero - expect(TBD.logs).to be_empty - expect(surfaces).to be_a(Hash) - expect(surfaces.size).to eq(43) - expect(io).to be_a(Hash) - expect(io).to have_key(:edges) - expect(io[:edges].size).to eq(109) + file = File.join(__dir__, "files/osms/out/seb2.osm") + path = OpenStudio::Path.new(file) + model = translator.loadModel(path) + expect(model).to_not be_empty + model = model.get - # Both diffuser and parent ceiling are stored as TBD 'surfaces'. - expect(surfaces).to have_key(s1) - surface = surfaces[s1] - expect(surface).to have_key(:skylights) - expect(surface[:skylights]).to have_key("diffuser") + # Consider the plenum as UNCONDITIONED. + plnum = model.getSpaceByName("Level 0 Ceiling Plenum") + expect(plnum).to_not be_empty + plnum = plnum.get + expect(TBD.unconditioned?(plnum)).to be false - skylight = surface[:skylights]["diffuser"] - expect(skylight).to have_key(:u) - expect(skylight[:u]).to be_a(Numeric) - expect(skylight[:u]).to be_within(TOL).of(1/rsi) + key = "space_conditioning_category" + val = "Unconditioned" + expect(plnum.additionalProperties.hasFeature(key)).to be false + expect(plnum.additionalProperties.setFeature(key, val)).to be true + expect(TBD.plenum?(plnum)).to be true + expect(TBD.unconditioned?(plnum)).to be true + expect(TBD.setpoints(plnum)[:heating]).to be_nil + expect(TBD.setpoints(plnum)[:cooling]).to be_nil + expect(TBD.status).to be_zero - # ... yet TBD only derates constructions of opaque surfaces in CONDITIONED - # spaces IF (i) facing outdoors or (ii) facing UNCONDITIONED spaces like - # attics (see psi.rb). Here, the ceiling is tagged by TBD as a deratable - # surface, and hence the diffuser edges are logged in TBD's 'edges'. - expect(surface).to have_key(:ratio) - expect(surface).to have_key(:heatloss) - expect(surface[:heatloss]).to be_within(TOL).of(2.00) # 4x 0.500 W/K + argh = {} + argh[:option ] = "(non thermal bridging)" + argh[:io_path ] = File.join(__dir__, "../json/tbd_seb_n6.json") + argh[:schama_path] = File.join(__dir__, "../tbd.schema.json") - # Only edges of the diffuser (linked to the ceiling) are stored. - io[:edges].each do |edge| - expect(edge).to be_a(Hash) - expect(edge).to have_key(:surfaces) - expect(edge[:surfaces]).to be_a(Array) + 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.status).to be_zero + expect(TBD.logs).to be_empty + expect(surfaces).to be_a(Hash) + expect(surfaces.size).to eq(56) + expect(io).to be_a(Hash) + expect(io).to have_key(:edges) + expect(io[:edges].size).to eq(80) - edge[:surfaces].each do |id| - next unless ["dome", "diffuser"].include?(id) + # As above, with a "good" surface PSI set + surfaces.each do |id, surface| + next unless surface[:boundary] == "outdoors" - expect(id).to eq("diffuser") - end - end + expect(surface).to_not have_key(:ratio) unless id == "Entryway Wall 5" + next unless id == "Entryway Wall 5" - expect(surfaces).to have_key(s3) - surface = surfaces[s3] - expect(surface).to have_key(:skylights) - expect(surface[:skylights]).to have_key("dome") + expect(surface[:heatloss]).to be_within(TOL).of(14.05) + end + end - skylight = surface[:skylights]["dome"] - expect(skylight).to have_key(:u) - expect(skylight[:u]).to be_a(Numeric) - expect(skylight[:u]).to be_within(TOL).of(1/rsi) - expect(surface).to_not have_key(:heatloss) - expect(surface).to_not have_key(:ratio) + it "can process JSON surface KHI & PSI + building & edge (4)" do + translator = OpenStudio::OSVersion::VersionTranslator.new + TBD.clean! - expect(io[:edges].size).to eq(109) # 4x extra edges for diffuser only + file = File.join(__dir__, "files/osms/out/seb2.osm") + path = OpenStudio::Path.new(file) + model = translator.loadModel(path) + expect(model).to_not be_empty + model = model.get - out = JSON.pretty_generate(io) - outP = File.join(__dir__, "../json/tbd_smalloffice1.out.json") + # Consider the plenum as UNCONDITIONED. + plnum = model.getSpaceByName("Level 0 Ceiling Plenum") + expect(plnum).to_not be_empty + plnum = plnum.get + expect(TBD.unconditioned?(plnum)).to be false - File.open(outP, "w") { |outP| outP.puts out } + key = "space_conditioning_category" + val = "Unconditioned" + expect(plnum.additionalProperties.hasFeature(key)).to be false + expect(plnum.additionalProperties.setFeature(key, val)).to be true + expect(TBD.plenum?(plnum)).to be true + expect(TBD.unconditioned?(plnum)).to be true + expect(TBD.setpoints(plnum)[:heating]).to be_nil + expect(TBD.setpoints(plnum)[:cooling]).to be_nil + expect(TBD.status).to be_zero - # Re-use the exported file as input for another test. - model2 = translator.loadModel(pth) - expect(model2).to_not be_empty - model2 = model2.get - jpath = "../json/tbd_smalloffice1.out.json" + argh = {} + argh[:option ] = "compliant" # superseded by :building PSI set on file + argh[:io_path ] = File.join(__dir__, "../json/tbd_seb_n7.json") + argh[:schema_path] = File.join(__dir__, "../tbd.schema.json") - argh[:schema_path] = File.join(__dir__, "../tbd.schema.json") - argh[:io_path ] = File.join(__dir__, jpath) + 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.status).to be_zero + expect(TBD.logs).to be_empty + expect(surfaces).to be_a Hash + expect(surfaces.size).to eq(56) + expect(io).to be_a(Hash) + expect(io).to have_key(:edges) + expect(io[:edges].size).to eq(80) - json2 = TBD.process(model2, argh) - expect(json2).to be_a(Hash) - expect(json2).to have_key(:io) - expect(json2).to have_key(:surfaces) - io2 = json2[:io ] - surfaces = json2[:surfaces] - expect(TBD.status).to be_zero - expect(TBD.logs).to be_empty - expect(surfaces).to be_a(Hash) - expect(surfaces.size).to eq(43) - expect(io).to be_a(Hash) - expect(io).to have_key(:edges) - expect(io[:edges].size).to eq(109) + # In the JSON file, the "Entry way 1" space "compliant" PSI set supersedes + # the default :building PSI set "(non thermal bridging)". The 3x walls below + # (4, 5 & 6) - part of "Entry way 1" - will inherit the "compliant" PSI set + # and hence their constructions will be derated. Exceptionally, Wall 5 has + # - in addition to a handful of point conductances - derating edges based on + # the "good" PSI set. Finally, edges between Wall 5 and its "Sub Surface 8" + # have their types overwritten (from :fenestration to :balcony), i.e. + # 0.8 W/K per m instead of 0.35 W/K per m. The latter is a weird one, but + # illustrates basic JSON functionality. A more realistic override: a switch + # between :corner to :fenestration (or vice versa) for corner windows. + surfaces.each do |id, surface| + walls = ["Entryway Wall 5", "Entryway Wall 6", "Entryway Wall 4"] + next unless surface[:boundary] == "outdoors" - # Now mimic (again) the export functionality of the measure. Both output - # files should be the same. - out2 = JSON.pretty_generate(io2) - outP2 = File.join(__dir__, "../json/tbd_smalloffice2.out.json") - File.open(outP2, "w") { |outP2| outP2.puts out2 } - expect(FileUtils).to be_identical(outP, outP2) + expect(surface).to have_key(:ratio) if walls.include?(id) + expect(surface).to_not have_key(:ratio) unless walls.include?(id) + next unless id == "Entryway Wall 5" + + expect(surface[:heatloss]).to be_within(TOL).of(15.62) end end - it "can handle air gaps as materials" do - translator = OpenStudio::OSVersion::VersionTranslator.new + it "can process JSON file read/validate" do TBD.clean! - 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 - id = "Bulk Storage Rear Wall" + argh = {} + argh[:schema_path] = File.join(__dir__, "../tbd.schema.json") + argh[:io_path ] = File.join(__dir__, "../json/tbd_json_test.json") - s = model.getSurfaceByName(id) - expect(s).to_not be_empty - s = s.get - expect(s.nameString).to eq(id) - expect(s.surfaceType).to eq("Wall") - expect(s.isConstructionDefaulted).to be true - c = s.construction.get.to_LayeredConstruction - expect(c).to_not be_empty - c = c.get - expect(c.numLayers).to eq(3) + expect(File.exist?(argh[:schema_path])).to be true + schema = File.read(argh[:schema_path]) + schema = JSON.parse(schema, symbolize_names: true) + io = File.read(argh[:io_path]) + io = JSON.parse(io, symbolize_names: true) + expect(JSON::Validator.validate(schema, io)).to be true + expect(io).to have_key(:description) + expect(io).to have_key(:schema) + expect(io).to have_key(:edges) + expect(io).to have_key(:surfaces) + expect(io).to have_key(:building) + expect(io).to_not have_key(:spaces) + expect(io).to_not have_key(:spacetypes) + expect(io).to_not have_key(:stories) + expect(io).to_not have_key(:logs) + expect(io[:edges].size ).to eq(1) + expect(io[:surfaces].size).to eq(1) - gap = OpenStudio::Model::AirGap.new(model) - expect(gap.handle.to_s).to_not be_empty - expect(gap.nameString).to_not be_empty - expect(gap.nameString).to eq("Material Air Gap 1") - gap.setName("#{id} air gap") - expect(gap.nameString).to eq("#{id} air gap") - expect(gap.setThermalResistance(0.180)).to be true - expect(gap.thermalResistance).to be_within(TOL).of(0.180) - expect(c.insertLayer(1, gap)).to be true - expect(c.numLayers).to eq(4) + # Loop through input psis to ensure uniqueness vs PSI defaults. + psi = TBD::PSI.new + expect(io).to have_key(:psis) - pth = File.join(__dir__, "files/osms/out/warehouse_airgap.osm") - model.save(pth, true) + io[:psis].each { |p| expect(psi.append(p)).to be true } - argh = { option: "poor (BETBG)" } + expect(psi.set.size).to eq(18) + expect(psi.set).to have_key("poor (BETBG)") + expect(psi.set).to have_key("regular (BETBG)") + expect(psi.set).to have_key("efficient (BETBG)") + expect(psi.set).to have_key("spandrel (BETBG)") + expect(psi.set).to have_key("spandrel HP (BETBG)") + expect(psi.set).to have_key("code (Quebec)") + expect(psi.set).to have_key("uncompliant (Quebec)") + expect(psi.set).to have_key("90.1.22|steel.m|default") + expect(psi.set).to have_key("90.1.22|steel.m|unmitigated") + expect(psi.set).to have_key("90.1.22|mass.ex|default") + expect(psi.set).to have_key("90.1.22|mass.ex|unmitigated") + expect(psi.set).to have_key("90.1.22|mass.in|default") + expect(psi.set).to have_key("90.1.22|mass.in|unmitigated") + expect(psi.set).to have_key("90.1.22|wood.fr|default") + expect(psi.set).to have_key("90.1.22|wood.fr|unmitigated") + expect(psi.set).to have_key("(non thermal bridging)") + expect(psi.set).to have_key("good") # appended + expect(psi.set).to have_key("compliant") # appended - TBD.process(model, argh) - expect(TBD.status).to be_zero - end + # Similar treatment for khis. + khi = TBD::KHI.new + expect(io).to have_key(:khis) - it "can uprate (ALL roof) constructions" do - translator = OpenStudio::OSVersion::VersionTranslator.new - TBD.clean! + io[:khis].each { |k| expect(khi.append(k)).to be true } - 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 - rf1 = "Typical Insulated Metal Building Roof R-10.31 1" - rf2 = "Typical Insulated Metal Building Roof R-18.18" + expect(khi.point.size).to eq(16) + expect(khi.point).to have_key("poor (BETBG)") + expect(khi.point).to have_key("regular (BETBG)") + expect(khi.point).to have_key("efficient (BETBG)") + expect(khi.point).to have_key("code (Quebec)") + expect(khi.point).to have_key("uncompliant (Quebec)") + expect(khi.point).to have_key("90.1.22|steel.m|default") + expect(khi.point).to have_key("90.1.22|steel.m|unmitigated") + expect(khi.point).to have_key("90.1.22|mass.ex|default") + expect(khi.point).to have_key("90.1.22|mass.ex|unmitigated") + expect(khi.point).to have_key("90.1.22|mass.in|default") + expect(khi.point).to have_key("90.1.22|mass.in|unmitigated") + expect(khi.point).to have_key("90.1.22|wood.fr|default") + expect(khi.point).to have_key("90.1.22|wood.fr|unmitigated") + expect(khi.point).to have_key("(non thermal bridging)") + expect(khi.point).to have_key("column") # appended + expect(khi.point).to have_key("support") # appended - model.getSurfaces.each do |s| - next unless s.surfaceType.downcase == "roofceiling" - next unless s.outsideBoundaryCondition.downcase == "outdoors" - next if s.construction.empty? - next if s.construction.get.to_LayeredConstruction.empty? + expect(khi.point["column" ]).to eq(0.5) + expect(khi.point["support"]).to eq(0.5) - lc = s.construction.get.to_LayeredConstruction.get - id = lc.nameString - flm = s.filmResistance - expect([rf1, rf2]).to include(id) - expect(flm.round(4)).to eq(0.1360) - expect(TBD.rsi(lc, flm).round(3)).to eq(1.814) if id == rf1 # R10 - expect(TBD.rsi(lc, flm).round(3)).to eq(3.201) if id == rf2 # R18 - end + expect(psi.set).to have_key("spandrel (BETBG)") + expect(psi.set).to have_key("spandrel HP (BETBG)") - # One challenge of the uprating approach concerns OpenStudio-reported - # surface film resistances, which factor-in the slope of the surface and - # surface emittances. As the uprate approach relies on user-defined Ut - # factors (inputs, as targets to meet), it also considers surface film - # resistances. In the schematic cross-section below, let's postulate that - # each slope has a unique pitch: 50deg (s1), 0deg (s2), & 60dge (s3). All - # three surfaces reference the same construction. - # - # s2 - # _____ - # / \ - # s1 / \ s3 - # / \ - # - # For highly-reflective interior finishes (think of Bruce Lee in Enter the - # Dragon), the difference here in reported RSi could reach 0.1 m2.K/W or - # R0.6. That's a 1% to 3% difference for a well-insulated construction. This - # may seem significant at first, but the impact on energy simulation results - # should barely be noticeable for well-insulated constructions. Yet such - # discrepancies can become an irritant when processing an OpenStudio model - # for code compliance purposes. This is more challenging when some envelope - # surfaces are INTERZONE (e.g. insulated attic floor). - # - # When uprating clear-field (Uo) calculations, prior TBD versions ensured - # that the shared layered construction met the minimal code requirements - # for the surface with the lowest surface air film resistance, here s2. - # Surfaces s1 & s3 would slightly overshoot the uprated Uo target. - # - # The v3.6 fix now averages out surface air film resistances, as follows: - # - # area-weighted filmRSI = 1 / ( ∑ ( 1/filmRSIi • AREAi ) / AREAt ) - # - # Relying on an area-weighted average of surface air film resistances, some - # surface will report final (derated) Ut values slightly below target, - # others slightly above. Yet the area-weighted average (UA-based) should - # match the code-required Ut requirement. - # - # The other v3.6 change is maintaining user-assigned constructions (i.e. - # not replacing them with a single, predominant roof or wall construction). - # Each construction is certainly uprated, then derated. Yet the original - # user-defined, non-insulating layers are maintained as is. + expect(io).to have_key(:building) + expect(io).to have_key(:surfaces) + expect(io[:building]).to have_key(:psi) + expect(io[:building][:psi]).to eq("compliant") + expect(psi.set).to have_key(io[:building][:psi]) - argh = {} - argh[:roof_option ] = "ALL roof constructions" - argh[:option ] = "poor (BETBG)" - argh[:uprate_roofs] = true - argh[:roof_ut ] = 0.138 # NECB 2017 (RSi 7.25 / R41) + io[:surfaces].each do |surface| + expect(surface).to have_key(:id) + expect(surface).to have_key(:psi) + expect(surface).to have_key(:khis) + expect(surface[:id ]).to eq("front wall") + expect(surface[:psi ]).to eq("good") + expect(surface[:khis].size).to eq(2) + expect(psi.set).to have_key(surface[:psi]) + + surface[:khis].each do |k| + expect(k).to have_key(:id) + expect(khi.point).to have_key(k[:id]) + expect(k[:count]).to eq(3) if k[:id] == "column" + expect(k[:count]).to eq(4) if k[:id] == "support" + end + end - 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.status).to be_zero - expect(TBD.logs).to be_empty - expect(surfaces).to be_a(Hash) - expect(surfaces.size).to eq(23) - expect(io).to be_a(Hash) expect(io).to have_key(:edges) - expect(io[:edges].size).to eq(300) - bulk = "Bulk Storage Roof" - fine = "Fine Storage Roof" + io[:edges].each do |edge| + expect(edge).to have_key(:surfaces) + expect(edge).to have_key(:psi) + expect(edge[:psi]).to eq("compliant") + expect(psi.set).to have_key(edge[:psi]) - # OpenStudio objects. - bulk_roof = model.getSurfaceByName(bulk) - fine_roof = model.getSurfaceByName(fine) - expect(bulk_roof).to_not be_empty - expect(fine_roof).to_not be_empty - bulk_roof = bulk_roof.get - fine_roof = fine_roof.get + edge[:surfaces].each { |surface| expect(surface).to eq("front wall") } + end - bulk_construction = bulk_roof.construction - fine_construction = fine_roof.construction - expect(bulk_construction).to_not be_empty - expect(fine_construction).to_not be_empty + # A reminder that built-in KHIs are not frozen ... + khi.point["code (Quebec)"] = 2.0 + expect(khi.point["code (Quebec)"]).to eq(2.0) - bulk_construction = bulk_construction.get.to_LayeredConstruction - fine_construction = fine_construction.get.to_LayeredConstruction - expect(bulk_construction).to_not be_empty - expect(fine_construction).to_not be_empty + # Load PSI combo JSON example - likely the most expected or common use. + argh[:io_path] = File.join(__dir__, "../json/tbd_PSI_combo.json") + + io = File.read(argh[:io_path]) + io = JSON.parse(io, symbolize_names: true) + expect(JSON::Validator.validate(schema, io)).to be true + expect(io).to have_key(:description) + expect(io).to have_key(:schema) + expect(io).to have_key(:spaces) + expect(io).to have_key(:building) + expect(io).to_not have_key(:spacetypes) + expect(io).to_not have_key(:stories) + expect(io).to_not have_key(:edges) + expect(io).to_not have_key(:surfaces) + expect(io).to_not have_key(:logs) + expect(io[:spaces].size).to eq(1) + + # Loop through input psis to ensure uniqueness vs PSI defaults. + psi = TBD::PSI.new + expect(io).to have_key(:psis) - bulk_construction = bulk_construction.get - fine_construction = fine_construction.get - expect(bulk_construction.nameString).to eq("Bulk Storage Roof c tbd") - expect(fine_construction.nameString).to eq("Fine Storage Roof c tbd") - expect(bulk_construction.layers.size).to eq(2) - expect(fine_construction.layers.size).to eq(2) + io[:psis].each { |p| expect(psi.append(p)).to be true } - bulk_insulation = bulk_construction.layers.at(1).to_MasslessOpaqueMaterial - fine_insulation = fine_construction.layers.at(1).to_MasslessOpaqueMaterial - expect(bulk_insulation).to_not be_empty - expect(fine_insulation).to_not be_empty + expect(psi.set.size).to eq(18) + expect(psi.set).to have_key("poor (BETBG)") + expect(psi.set).to have_key("regular (BETBG)") + expect(psi.set).to have_key("efficient (BETBG)") + expect(psi.set).to have_key("spandrel (BETBG)") + expect(psi.set).to have_key("spandrel HP (BETBG)") + expect(psi.set).to have_key("code (Quebec)") + expect(psi.set).to have_key("uncompliant (Quebec)") + expect(psi.set).to have_key("90.1.22|steel.m|default") + expect(psi.set).to have_key("90.1.22|steel.m|unmitigated") + expect(psi.set).to have_key("90.1.22|mass.ex|default") + expect(psi.set).to have_key("90.1.22|mass.ex|unmitigated") + expect(psi.set).to have_key("90.1.22|mass.in|default") + expect(psi.set).to have_key("90.1.22|mass.in|unmitigated") + expect(psi.set).to have_key("90.1.22|wood.fr|default") + expect(psi.set).to have_key("90.1.22|wood.fr|unmitigated") + expect(psi.set).to have_key("(non thermal bridging)") + expect(psi.set).to have_key("OK") # appended + expect(psi.set).to have_key("Awesome") # appended - bulk_insulation = bulk_insulation.get - fine_insulation = fine_insulation.get - bulk_insulation_r = bulk_insulation.thermalResistance - fine_insulation_r = fine_insulation.thermalResistance - expect(bulk_insulation_r.round(3)).to eq(7.110) # once derated - expect(fine_insulation_r.round(3)).to eq(7.110) # once derated + expect(psi.set["Awesome"][:rimjoist]).to eq(0.2) + expect(io).to have_key(:building) + expect(io[:building]).to have_key(:psi) + expect(io[:building][:psi]).to eq("Awesome") + expect(psi.set).to have_key(io[:building][:psi]) + expect(io).to have_key(:spaces) - # Both constructions are uprated, then derated to meet the same NECB target. - rsi_bulk = TBD.rsi(bulk_construction, bulk_roof.filmResistance) - rsi_fine = TBD.rsi(fine_construction, fine_roof.filmResistance) - usi_fine = 1 / rsi_fine - expect(rsi_bulk.round(3)).to eq(rsi_fine.round(3)) - expect(usi_fine.round(3)).to eq(argh[:roof_ut]) + io[:spaces].each do |space| + expect(space).to have_key(:psi) + expect(space[:id ]).to eq("ground-floor restaurant") + expect(space[:psi]).to eq("OK") + expect(psi.set).to have_key(space[:psi]) + end - # TBD objects. - expect(surfaces).to have_key(bulk) - expect(surfaces).to have_key(fine) - expect(surfaces[bulk]).to have_key(:heatloss) - expect(surfaces[fine]).to have_key(:heatloss) - expect(surfaces[bulk]).to have_key(:net) - expect(surfaces[fine]).to have_key(:net) + # Load PSI combo2 JSON example - a more elaborate example, yet common. + # Post-JSON validation required to handle case sensitive keys & value + # strings (e.g. "ok" vs "OK" in the file). + argh[:io_path] = File.join(__dir__, "../json/tbd_PSI_combo2.json") - expect(surfaces[bulk][:heatloss].round(2)).to eq(161.02) - expect(surfaces[fine][:heatloss].round(2)).to eq( 87.16) - expect(surfaces[bulk][:net ].round(2)).to eq(3157.28) - expect(surfaces[fine][:net ].round(2)).to eq(1372.60) + io = File.read(argh[:io_path]) + io = JSON.parse(io, symbolize_names: true) + expect(JSON::Validator.validate(schema, io)).to be true + expect(io).to have_key(:description) + expect(io).to have_key(:schema) + expect(io).to have_key(:edges) + expect(io).to have_key(:surfaces) + expect(io).to have_key(:building) + expect(io).to_not have_key(:spaces) + expect(io).to_not have_key(:spacetypes) + expect(io).to_not have_key(:stories) + expect(io).to_not have_key(:logs) + expect(io[:edges ].size).to eq(1) + expect(io[:surfaces].size).to eq(1) - heatloss = surfaces[bulk][:heatloss] + surfaces[fine][:heatloss] - area = surfaces[bulk][:net ] + surfaces[fine][:net ] + # Loop through input psis to ensure uniqueness vs PSI defaults. + psi = TBD::PSI.new + expect(io).to have_key(:psis) - expect(heatloss.round(2)).to eq( 248.19) - expect( area.round(2)).to eq(4529.88) + io[:psis].each { |pzi| expect(psi.append(pzi)).to be true } - # The TBD data model tracks the initially-uprated constructions. - expect(surfaces[bulk]).to have_key(:construction) # not yet derated - expect(surfaces[fine]).to have_key(:construction) + expect(psi.set.size).to eq(19) + expect(psi.set).to have_key("poor (BETBG)") + expect(psi.set).to have_key("regular (BETBG)") + expect(psi.set).to have_key("efficient (BETBG)") + expect(psi.set).to have_key("spandrel (BETBG)") + expect(psi.set).to have_key("spandrel HP (BETBG)") + expect(psi.set).to have_key("code (Quebec)") + expect(psi.set).to have_key("uncompliant (Quebec)") + expect(psi.set).to have_key("90.1.22|steel.m|default") + expect(psi.set).to have_key("90.1.22|steel.m|unmitigated") + expect(psi.set).to have_key("90.1.22|mass.ex|default") + expect(psi.set).to have_key("90.1.22|mass.ex|unmitigated") + expect(psi.set).to have_key("90.1.22|mass.in|default") + expect(psi.set).to have_key("90.1.22|mass.in|unmitigated") + expect(psi.set).to have_key("90.1.22|wood.fr|default") + expect(psi.set).to have_key("90.1.22|wood.fr|unmitigated") + expect(psi.set).to have_key("(non thermal bridging)") + expect(psi.set).to have_key("OK") # appended + expect(psi.set).to have_key("Awesome") # appended + expect(psi.set).to have_key("Party wall edge") # appended - expect(surfaces[bulk][:construction].nameString).to eq(rf1) - expect(surfaces[fine][:construction].nameString).to eq(rf2) + expect(psi.set["Party wall edge"][:party]).to eq(0.4) + expect(io).to have_key(:surfaces) + expect(io).to have_key(:building) + expect(io[:building]).to have_key(:psi) + expect(io[:building][:psi]).to eq("Awesome") + expect(psi.set).to have_key(io[:building][:psi]) - # The initially-uprated roof construction is maintained in the model. - uprated = model.getConstructionByName(rf1) - expect(uprated).to_not be_empty - uprated = uprated.get - expect(uprated.to_LayeredConstruction).to_not be_empty - uprated = uprated.to_LayeredConstruction.get + io[:surfaces].each do |surface| + expect(surface).to have_key(:id) + expect(surface).to have_key(:psi) + expect(surface[:id ]).to eq("ground-floor restaurant South-wall") + expect(surface[:psi]).to eq("ok") + expect(psi.set).to_not have_key(surface[:psi]) + end - expect(uprated.is_a?(OpenStudio::Model::LayeredConstruction)).to be true - expect(uprated.layers.size).to eq(2) + expect(io).to have_key(:edges) + wlls = [] + wlls << "ground-floor restaurant West-wall" + wlls << "ground-floor restaurant party wall" - uprated.layers.each do |layer| - next unless layer.nameString.include?(" uprated") + io[:edges].each do |edge| + expect(edge).to have_key(:type) + expect(edge).to have_key(:psi) + expect(edge[:psi]).to eq("Party wall edge") + expect(edge[:type].to_s).to include("party") + expect(psi.set).to have_key(edge[:psi]) + expect(psi.set[edge[:psi]]).to have_key(:party) + expect(edge).to have_key(:surfaces) - expect(layer.to_MasslessOpaqueMaterial).to_not be_empty - layer = layer.to_MasslessOpaqueMaterial.get - expect(layer.thermalResistance.round(2)).to eq(11.16) # m2.K/W (R63) + edge[:surfaces].each { |surface| expect(wlls).to include(surface) } end - file = File.join(__dir__, "files/osms/out/up_warehouse.osm") - model.save(file, true) - end + # Load full PSI JSON example - with duplicate keys for "party". + # "JSON Schema Lint" (*) will recognize the duplicate and - as with + # duplicate Ruby hash keys - will have the second entry ("party": 0.8) + # override the first ("party": 0.7). Another reminder of post-JSON + # validation. + # + # * https://jsonschemalint.com/#!/version/draft-04/markup/json + argh[:io_path] = File.join(__dir__, "../json/tbd_full_PSI.json") - it "can uprate (ALL wall) constructions - poor (BETBG)" do - translator = OpenStudio::OSVersion::VersionTranslator.new - TBD.clean! + io = File.read(argh[:io_path]) + io = JSON.parse(io, symbolize_names: true) + expect(JSON::Validator.validate(schema, io)).to be true - 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 + expect(io).to have_key(:description) + expect(io).to have_key(:schema) + expect(io).to_not have_key(:edges) + expect(io).to_not have_key(:surfaces) + expect(io).to_not have_key(:spaces) + expect(io).to_not have_key(:spacetypes) + expect(io).to_not have_key(:stories) + expect(io).to_not have_key(:building) + expect(io).to_not have_key(:logs) - w1 = "Typical Insulated Metal Building Wall R-8.85 1" - w2 = "Typical Insulated Metal Building Wall R-11.9" - w3 = "Typical Insulated Metal Building Wall R-11.9 1" + # Loop through input psis to ensure uniqueness vs PSI defaults. + psi = TBD::PSI.new + expect(io).to have_key(:psis) - model.getSurfaces.each do |s| - next unless s.surfaceType.downcase == "wall" - next unless s.outsideBoundaryCondition.downcase == "outdoors" - next if s.construction.empty? - next if s.construction.get.to_LayeredConstruction.empty? + io[:psis].each { |p| expect(psi.append(p)).to be true } - lc = s.construction.get.to_LayeredConstruction.get - id = lc.nameString - flm = s.filmResistance - expect([w1, w2, w3]).to include(id) - expect(flm.round(4)).to eq(0.1496) - expect(TBD.rsi(lc, flm).round(3)).to eq(1.558) if id == w1 # R08.8 - expect(TBD.rsi(lc, flm).round(3)).to eq(2.096) if id == w2 # R11.9 - expect(TBD.rsi(lc, flm).round(3)).to eq(2.096) if id == w3 # R11.9 - end + expect(psi.set.size).to eq(17) + expect(psi.set).to have_key("poor (BETBG)") + expect(psi.set).to have_key("regular (BETBG)") + expect(psi.set).to have_key("efficient (BETBG)") + expect(psi.set).to have_key("spandrel (BETBG)") + expect(psi.set).to have_key("spandrel HP (BETBG)") + expect(psi.set).to have_key("code (Quebec)") + expect(psi.set).to have_key("uncompliant (Quebec)") + expect(psi.set).to have_key("90.1.22|steel.m|default") + expect(psi.set).to have_key("90.1.22|steel.m|unmitigated") + expect(psi.set).to have_key("90.1.22|mass.ex|default") + expect(psi.set).to have_key("90.1.22|mass.ex|unmitigated") + expect(psi.set).to have_key("90.1.22|mass.in|default") + expect(psi.set).to have_key("90.1.22|mass.in|unmitigated") + expect(psi.set).to have_key("90.1.22|wood.fr|default") + expect(psi.set).to have_key("90.1.22|wood.fr|unmitigated") + expect(psi.set).to have_key("(non thermal bridging)") + expect(psi.set).to have_key("OK") # appended - # Deeper dive into w1 (more prevalent). - targeted = model.getConstructionByName(w1) - expect(targeted).to_not be_empty - targeted = targeted.get - expect(targeted.to_LayeredConstruction).to_not be_empty - targeted = targeted.to_LayeredConstruction.get - expect(targeted.is_a?(OpenStudio::Model::LayeredConstruction)).to be true - expect(targeted.layers.size).to eq(3) + expect(psi.set["OK"][:party]).to eq(0.8) - targeted.layers.each do |layer| - next unless layer.nameString == "Typical Insulation R-7.55 1" - expect(layer.to_MasslessOpaqueMaterial).to_not be_empty - layer = layer.to_MasslessOpaqueMaterial.get - expect(layer.thermalResistance).to be_within(TOL).of(1.33) # m2.K/W (R7.6) - end + # Load minimal PSI JSON example. + argh[:io_path] = File.join(__dir__, "../json/tbd_minimal_PSI.json") + + io = File.read(argh[:io_path]) + io = JSON.parse(io, symbolize_names: true) + expect(JSON::Validator.validate(schema, io)).to be true + + # Load minimal KHI JSON example. + argh[:io_path] = File.join(__dir__, "../json/tbd_minimal_KHI.json") + + io = File.read(argh[:io_path]) + io = JSON.parse(io, symbolize_names: true) + expect(JSON::Validator.validate(schema, io)).to be true + v = JSON::Validator.validate(argh[:schema_path], argh[:io_path], uri: true) + expect(v).to be true - # Set w1 (a wall construction) as the 'Bulk Storage Roof' construction. This - # triggers a TBD warning when uprating: a safeguard limiting uprated - # constructions to single surface types (e.g. can't be referenced by both - # roof AND wall surfaces). - bulk = "Bulk Storage Roof" - bulk_roof = model.getSurfaceByName(bulk) - expect(bulk_roof).to_not be_empty - bulk_roof = bulk_roof.get - expect(bulk_roof.isConstructionDefaulted).to be true + # Load complete results (ex. UA') example. + argh[:io_path] = File.join(__dir__, "../json/tbd_warehouse11.json") - bulk_construction = bulk_roof.construction - expect(bulk_construction).to_not be_empty - bulk_construction = bulk_construction.get.to_LayeredConstruction - expect(bulk_construction).to_not be_empty - bulk_construction = bulk_construction.get - expect(bulk_construction.numLayers).to eq(2) - expect(bulk_roof.setConstruction(targeted)).to be true - expect(bulk_roof.isConstructionDefaulted).to be false + io = File.read(argh[:io_path]) + io = JSON.parse(io, symbolize_names: true) + expect(JSON::Validator.validate(schema, io)).to be true + v = JSON::Validator.validate(argh[:schema_path], argh[:io_path], uri: true) + expect(v).to be true + end - argh = {} - argh[:wall_option ] = "ALL wall constructions" - argh[:option ] = "poor (BETBG)" - argh[:uprate_walls] = true - argh[:wall_ut ] = 0.210 # (R27), NECB 2017 + it "can factor in spacetype-specific PSI sets (JSON input)" do + translator = OpenStudio::OSVersion::VersionTranslator.new + TBD.clean! + + 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 + + argh = {} + argh[:option ] = "compliant" # superseded by :building PSI set on file + argh[:io_path ] = File.join(__dir__, "../json/tbd_warehouse5.json") + argh[:schema_path] = File.join(__dir__, "../tbd.schema.json") json = TBD.process(model, argh) expect(json).to be_a(Hash) @@ -7579,136 +7362,57 @@ expect(json).to have_key(:surfaces) io = json[:io ] surfaces = json[:surfaces] - - # PSI-factors of the "poor (BETBG)" set are too conductive. The total heat - # loss (W/K) from thermal bridging is too great for insulation materials to - # absorb (given TBD/OSut admissible ranges). TBD fails to completely uprate - # walls to meet NECB 2017 Ut requirements. In such cases, TBD logs the - # failure, yet partially uprates non-compliant wall constructions by setting - # the uprated Uo to UMIN. - expect(TBD.warn?).to be true - expect(TBD.logs.size).to eq(3) - expect(TBD.logs[0][:message]).to include("Cloning 'Bulk Storage Roof' ") - expect(TBD.logs[1][:message]).to include("Negative ") - expect(TBD.logs[2][:message]).to include("Unable to completely uprate ") + expect(TBD.status).to be_zero + expect(TBD.logs).to be_empty expect(surfaces).to be_a(Hash) expect(surfaces.size).to eq(23) expect(io).to be_a(Hash) expect(io).to have_key(:edges) expect(io[:edges].size).to eq(300) - bulk_roof = model.getSurfaceByName(bulk) - expect(bulk_roof).to_not be_empty - bulk_roof = bulk_roof.get - - bulk_construction = bulk_roof.construction - expect(bulk_construction).to_not be_empty - bulk_construction = bulk_construction.get.to_LayeredConstruction - expect(bulk_construction).to_not be_empty - bulk_construction = bulk_construction.get - expect(bulk_construction.nameString).to eq("#{bulk} c tbd") - expect(bulk_construction.numLayers ).to eq(3) # not 2 - - layer0 = bulk_construction.layers[0] - layer1 = bulk_construction.layers[1] - layer2 = bulk_construction.layers[2] - expect(layer1.nameString).to eq("#{bulk} m tbd") # not uprated - - uA = 0 - m2 = 0 - - model.getSurfaces.each do |s| - next unless s.surfaceType.downcase == "wall" - next unless s.outsideBoundaryCondition.downcase == "outdoors" - - expect(s.construction).to_not be_empty - expect(s.construction.get.to_LayeredConstruction).to_not be_empty - c = s.construction.get.to_LayeredConstruction.get - expect(c.numLayers).to eq(3) - expect(c.layers[0]).to eq(layer0) # same as Bulk Storage Roof - expect(c.layers[1].nameString).to include(" uprated ") - expect(c.layers[1].nameString).to include(" m tbd") - expect(c.layers[2]).to eq(layer2) # same as Bulk Storage Roof + types = ["Warehouse Office", "Warehouse Fine"] + expect(io).to have_key(:spacetypes) - m2 += s.netArea - uA += s.netArea / TBD.rsi(c, s.filmResistance) + io[:spacetypes].each do |spacetype| + expect(spacetype).to have_key(:psi) + expect(spacetype).to have_key(:id) + expect(types).to include(spacetype[:id]) end - ut = uA / m2 - expect(ut.round(3)).to eq(0.226) # R21, below the required R27 - - # TBD objects. - expect(surfaces).to have_key(bulk) - expect(surfaces[bulk]).to have_key(:heatloss) - expect(surfaces[bulk]).to have_key(:net) + surfaces.each do |id, surface| + next unless surface[:boundary] == "outdoors" + next unless surface.key?(:ratio) - # By initially inheriting the wall construction, the bulk roof surface is - # slightly less derated (152.40 W/K instead of 161.02 W/K), due to TBD's - # proportionate psi distribution between surface edges. - expect(surfaces[bulk][:heatloss]).to be_within(TOL).of(152.40) - expect(surfaces[bulk][:net]).to be_within(TOL).of(3157.28) - expect(surfaces[bulk]).to have_key(:construction) # not yet derated - nom = surfaces[bulk][:construction].nameString - expect(nom).to include("cloned") + expect(surface).to have_key(:heatloss) + heatloss = surface[:heatloss] + expect(heatloss.abs).to be > 0 + expect(surface).to have_key(:space) + next unless surface[:space].nameString == "Zone1 Office" - file = File.join(__dir__, "files/osms/out/up2_warehouse.osm") - model.save(file, true) + # All applicable thermal bridges/edges derating the office walls inherit + # the "Warehouse Office" spacetype PSI values (JSON file), except for the + # shared :rimjoist with the Fine Storage space above. The "Warehouse Fine" + # spacetype set has a higher :rimjoist PSI value of 0.5 W/K per metre, + # which overrides the "Warehouse Office" value of 0.3 W/K per metre. + expect(heatloss).to be_within(TOL).of(11.61) if id == "Office Left Wall" + expect(heatloss).to be_within(TOL).of(22.94) if id == "Office Front Wall" + end end - it "can uprate (ALL wall) constructions - efficient (BETBG)" do + it "can factor in story-specific PSI sets (JSON input)" do translator = OpenStudio::OSVersion::VersionTranslator.new TBD.clean! - file = File.join(__dir__, "files/osms/in/warehouse.osm") + file = File.join(__dir__, "files/osms/in/smalloffice.osm") path = OpenStudio::Path.new(file) model = translator.loadModel(path) expect(model).to_not be_empty model = model.get - w1 = "Typical Insulated Metal Building Wall R-8.85 1" - w2 = "Typical Insulated Metal Building Wall R-11.9" - w3 = "Typical Insulated Metal Building Wall R-11.9 1" - - # Deeper dive into w1 (more prevalent). - targeted = model.getConstructionByName(w1) - expect(targeted).to_not be_empty - targeted = targeted.get - expect(targeted.to_LayeredConstruction).to_not be_empty - targeted = targeted.to_LayeredConstruction.get - expect(targeted.is_a?(OpenStudio::Model::LayeredConstruction)).to be true - expect(targeted.layers.size).to eq(3) - - targeted.layers.each do |layer| - next unless layer.nameString == "Typical Insulation R-7.55 1" - expect(layer.to_MasslessOpaqueMaterial).to_not be_empty - layer = layer.to_MasslessOpaqueMaterial.get - expect(layer.thermalResistance).to be_within(TOL).of(1.33) # m2.K/W (R7.6) - end - - # Set w1 (a wall construction) as the 'Bulk Storage Roof' construction. This - # triggers a TBD warning when uprating: a safeguard limiting uprated - # constructions to single surface type (e.g. can't be referenced by both - # roof AND wall surfaces). - bulk = "Bulk Storage Roof" - bulk_roof = model.getSurfaceByName(bulk) - expect(bulk_roof).to_not be_empty - bulk_roof = bulk_roof.get - expect(bulk_roof.isConstructionDefaulted).to be true - - bulk_construction = bulk_roof.construction - expect(bulk_construction).to_not be_empty - bulk_construction = bulk_construction.get.to_LayeredConstruction - expect(bulk_construction).to_not be_empty - bulk_construction = bulk_construction.get - expect(bulk_construction.numLayers).to eq(2) - expect(bulk_roof.setConstruction(targeted)).to be true - expect(bulk_roof.isConstructionDefaulted).to be false - - argh = {} - argh[:wall_option ] = "ALL wall constructions" - argh[:option ] = "efficient (BETBG)" # vs preceding test - argh[:uprate_walls] = true - argh[:wall_ut ] = 0.210 # (R27) + argh = {} + argh[:option ] = "compliant" # superseded by :building PSI set on file + argh[:io_path ] = File.join(__dir__, "../json/tbd_smalloffice.json") + argh[:schema_path] = File.join(__dir__, "../tbd.schema.json") json = TBD.process(model, argh) expect(json).to be_a(Hash) @@ -7716,116 +7420,48 @@ expect(json).to have_key(:surfaces) io = json[:io ] surfaces = json[:surfaces] - - expect(TBD.warn?).to be true - expect(TBD.logs.size).to eq(1) + expect(TBD.status).to be_zero + expect(TBD.logs).to be_empty expect(surfaces).to be_a(Hash) - expect(surfaces.size).to eq(23) + expect(surfaces.size).to eq(43) expect(io).to be_a(Hash) + expect(io).to have_key(:stories) expect(io).to have_key(:edges) - expect(io[:edges].size).to eq(300) - - msg = "Cloning '#{bulk}' construction - not '#{w1}' (TBD::uprate)" - expect(TBD.logs.first[:message]).to eq(msg) - - bulk_roof = model.getSurfaceByName(bulk) - expect(bulk_roof).to_not be_empty - bulk_roof = bulk_roof.get - - bulk_construction = bulk_roof.construction - expect(bulk_construction).to_not be_empty - bulk_construction = bulk_construction.get.to_LayeredConstruction - expect(bulk_construction).to_not be_empty - bulk_construction = bulk_construction.get - expect(bulk_construction.nameString).to eq("#{bulk} c tbd") - expect(bulk_construction.numLayers).to eq(3) # not 2 - - layer0 = bulk_construction.layers[0] - layer1 = bulk_construction.layers[1] - layer2 = bulk_construction.layers[2] - expect(layer1.nameString).to eq("#{bulk} m tbd") # not uprated - - uA = 0 - m2 = 0 - - model.getSurfaces.each do |s| - next unless s.surfaceType.downcase == "wall" - next unless s.outsideBoundaryCondition.downcase == "outdoors" - - expect(s.construction).to_not be_empty - expect(s.construction.get.to_LayeredConstruction).to_not be_empty - c = s.construction.get.to_LayeredConstruction.get - expect(c.numLayers).to eq(3) - expect(c.layers[0]).to eq(layer0) # same as Bulk Storage Roof - expect(c.layers[1].nameString).to include(" uprated ") - expect(c.layers[1].nameString).to include(" m tbd") - expect(c.layers[2]).to eq(layer2) # same as Bul;k Storage Roof + expect(io[:edges].size).to eq(105) - m2 += s.netArea - uA += s.netArea / TBD.rsi(c, s.filmResistance) + io[:stories].each do |story| + expect(story).to have_key(:psi) + expect(story).to have_key(:id) + expect(story[:id]).to eq("Building Story 1") end - ut = uA / m2 - expect(ut.round(3)).to eq(0.210) # R27, per NECB 2017 requirements + surfaces.each do |id, surface| + next unless surface.key?(:ratio) - # TBD objects. - expect(surfaces).to have_key(bulk) - expect(surfaces[bulk]).to have_key(:net) - expect(surfaces[bulk]).to have_key(:heatloss) - expect(surfaces[bulk][:heatloss]).to be_within(TOL).of( 49.80) - expect(surfaces[bulk][:net ]).to be_within(TOL).of(3157.28) - expect(surfaces[bulk]).to have_key(:construction) # not yet derated - nom = surfaces[bulk][:construction].nameString - expect(nom).to include("cloned") + expect(surface).to have_key(:heatloss) + expect(surface[:heatloss].abs).to be > 0 + next unless surface.key?(:story) - file = File.join(__dir__, "files/osms/out/up3_warehouse.osm") - model.save(file, true) + expect(surface[:story].nameString).to eq("Building Story 1") + end end - it "can test 5ZoneNoHVAC (failed) uprating" do + it "can sort multiple story-specific PSI sets (JSON input)" do translator = OpenStudio::OSVersion::VersionTranslator.new TBD.clean! - walls = [] - id = "ASHRAE 189.1-2009 ExtWall Mass ClimateZone 5" - file = File.join(__dir__, "files/osms/in/5ZoneNoHVAC.osm") + file = File.join(__dir__, "files/osms/in/midrise.osm") path = OpenStudio::Path.new(file) model = translator.loadModel(path) expect(model).to_not be_empty model = model.get - # Get geometry data for testing (4x exterior walls, same construction). - construction = nil - - model.getSurfaces.each do |s| - next unless s.surfaceType == "Wall" - next unless s.outsideBoundaryCondition == "Outdoors" - - walls << s.nameString - c = s.construction - expect(c).to_not be_empty - c = c.get.to_LayeredConstruction - expect(c).to_not be_empty - c = c.get - - construction = c if construction.nil? - expect(c).to eq(construction) - end - - expect(walls.size ).to eq( 4) - expect(construction.nameString ).to eq(id) - expect(construction.layers.size).to eq( 4) - - insulation = construction.layers[2].to_StandardOpaqueMaterial - expect(insulation).to_not be_empty - insulation = insulation.get - 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.round(4)).to eq(1.8380) + argh = {} + argh[:option ] = "(non thermal bridging)" # overridden + argh[:io_path ] = File.join(__dir__, "../json/midrise.json") + argh[:schema_path] = File.join(__dir__, "../tbd.schema.json") - 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) @@ -7833,144 +7469,142 @@ surfaces = json[:surfaces] expect(TBD.status).to be_zero expect(TBD.logs).to be_empty + expect(surfaces).to be_a(Hash) + expect(surfaces.size).to eq(180) + expect(io).to be_a(Hash) + expect(io).to have_key(:edges) + expect(io[:edges].size).to eq(282) - walls.each do |wall| - expect(surfaces).to have_key(wall) - expect(surfaces[wall]).to have_key(:heatloss) + counter = 0 + stories = ["Building Story 1", "Building Story 2", "Building Story 3"] + edges = [:parapetconvex, :transition] + expect(io).to have_key(:stories) + expect(io[:stories].size).to eq(stories.size) - long = (surfaces[wall][:heatloss] - 27.746).abs < TOL # 40 metres wide - short = (surfaces[wall][:heatloss] - 14.548).abs < TOL # 20 metres wide - expect(long || short).to be true + io[:stories].each do |story| + expect(story).to have_key(:id) + expect(story).to have_key(:psi) + expect(stories).to include(story[:id]) end - # The 4-sided model has 2x "long" front/back + 2x "short" side exterior - # walls, with a total TBD-calculated heat loss (from thermal bridging) of: - # - # 2x 27.746 W/K + 2x 14.548 W/K = ~84.588 W/K - # - # Spread over ~273.6 m2 of gross wall area, that is A LOT! Why (given the - # "efficient" PSI-factors)? Each wall has a long "strip" window, almost the - # full wall width (reaching to within a few millimetres of each corner). - # This ~slices the host wall into 2x very narrow strips. Although the - # thermal bridging details are considered "efficient", the total length of - # linear thermal bridges is very high given the limited exposed (gross) - # area. If area-weighted, derating the insulation layer of the referenced - # wall construction above would entail factoring in this extra thermal - # conductance of ~0.309 W/m2•K (84.6/273.6), which would increase the - # insulation conductivity quite significantly. - # - # Ut = Uo + ( ∑psi • L )/A - # - # Expressed otherwise: - # - # Ut = Uo + 0.309 - # - # So what initial Uo factor should the construction offer (prior to - # derating) to ensure compliance with NECB2017/2020 prescriptive - # requirements (one of the few energy codes with prescriptive Ut - # requirements)? For climate zone 7, the target Ut is 0.210 W/m2•K (Rsi - # 4.76 m2•K/W or R27). Taking into account air film resistances and - # non-insulating layer resistances (e.g. ~Rsi 1 m2•K/W), the prescribed - # (max) layer Ut becomes ~0.277 (Rsi 3.6 or R20.5). - # - # 0.277 = Uo? + 0.309 - # - # Duh-oh! Even with an infinitely thick insulation layer (Uo ~= 0), it - # would be impossible to reach NECB2017/2020 prescritive requirements with - # "efficient" thermal breaks. Solutions? Eliminate windows :\ Otherwise, - # further improve detailing as to achieve ~0.1 W/K per linear metre - # (easier said than done). Here, an average PSI-factor of 0.150 W/K per - # linear metre (i.e. ~76.1 W/K instead of ~84.6 W/K) still won't cut it - # for a Uo of 0.01 W/m2•K (Rsi 100 or R568). Instead, an average PSI-factor - # of 0.090 (~45.6 W/K, very high performance) would allow compliance for a - # Uo of 0.1 W/m2•K (Rsi 10 or R57, ... $$$). - # - # 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. - # - # And if one were to instead model each of the OpenStudio walls described - # above as 2x distinct OpenStudio surfaces? e.g.: - # - 95% of exposed wall area Uo 0.01 W/m2•K - # - 5% of exposed wall area as a "thermal bridge" strip (~5.6 W/m2•K *) - # - # * (76.1 W/K over 5% of 273.6 m2) - # - # One would still consistently arrive at the same area-weighted average - # Ut, in this case 0.288 (> 0.277). No free lunches. - # - # --- - # - # TBD's "uprating" method reorders the equation and attempts the - # following: - # - # Uo = 0.277 - ( ∑psi • L )/A - # - # The method exits with an ERROR in 2x cases: - # - calculated Uo is negative, i.e. ( ∑psi • L )/A > 0.277 - # - calculated layer r violates E+ material constraints, e.g. - # - too conductive - # - too thin + surfaces.each do |id, surface| + next unless surface.key?(:ratio) - # -- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- -- # - # Retrying the previous example, yet requesting uprating calculations: + expect(surface).to have_key(:edges) + expect(surface).to have_key(:story) + expect(surface).to have_key(:boundary) + expect(surface[:boundary]).to eq("outdoors") + nom = surface[:story].nameString + expect(stories).to include(nom) + expect(nom).to eq(stories[0]) if id.include?("g ") + expect(nom).to eq(stories[1]) if id.include?("m ") + expect(nom).to eq(stories[2]) if id.include?("t ") + + counter += 1 + + # Illustrating that story-specific PSI set is used when only 1x story. + surface[:edges].values.each do |edge| + expect(edge).to have_key(:type) + expect(edge).to have_key(:psi) + next unless id.include?("Roof") + + expect(edges).to include(edge[:type]) + next if edge[:type] == :transition + next if id == "t Roof C" + + expect(edge[:psi]).to be_within(TOL).of(0.178) # 57.3% of 0.311 + end + + # Illustrating that story-specific PSI set is used when only 1x story. + surface[:edges].values.each do |edge| + next unless id.include?("t ") + next unless id.include?("Wall ") + next unless edge[:type] == :parapetconvex + next if id.include?(" C") + + expect(edge[:psi]).to be_within(TOL).of(0.133) # 42.7% of 0.311 + end + + # The shared :rimjoist between middle story and ground floor units could + # either inherit the "Building Story 1" or "Building Story 2" :rimjoist + # PSI values. TBD retains the most conductive PSI values in such cases. + surface[:edges].values.each do |edge| + next unless id.include?("m ") + next unless id.include?("Wall ") + next if id.include?(" C") + next unless edge[:type] == :rimjoist + + # Inheriting "Building Story 1" :rimjoist PSI of 0.501 W/K per metre. + # The SEA unit is above an office space below, which has curtain wall. + # RSi of insulation layers (to derate): + # - office walls : 0.740 m2.K/W (26.1%) + # - SEA walls : 2.100 m2.K/W (73.9%) + # + # - SEA walls : 26.1% of 0.501 = 0.3702 W/K per metre + # - other walls : 50.0% of 0.501 = 0.2505 W/K per metre + if ["m SWall SEA", "m EWall SEA"].include?(id) + expect(edge[:psi]).to be_within(0.002).of(0.3702) + else + expect(edge[:psi]).to be_within(0.002).of(0.2505) + end + end + end + + expect(counter).to eq(51) + end + + it "can handle parties" do + translator = OpenStudio::OSVersion::VersionTranslator.new TBD.clean! + file = File.join(__dir__, "files/osms/out/seb2.osm") + path = OpenStudio::Path.new(file) model = translator.loadModel(path) expect(model).to_not be_empty model = model.get - argh = {} - argh[:option ] = "efficient (BETBG)" # all PSI-factors @ 0.2 W/K•m - argh[:uprate_walls] = true - argh[:uprate_roofs] = true - argh[:wall_option ] = "ALL wall constructions" - argh[:roof_option ] = "ALL roof constructions" - argh[:wall_ut ] = 0.210 # NECB CZ7 2017 (RSi 4.76 / R27) - argh[:roof_ut ] = 0.138 # NECB CZ7 2017 (RSi 7.25 / R41) + # Consider the plenum as UNCONDITIONED. + plnum = model.getSpaceByName("Level 0 Ceiling Plenum") + expect(plnum).to_not be_empty + plnum = plnum.get + expect(TBD.unconditioned?(plnum)).to be false - 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 have_key(:roof_uo) - expect(argh[:wall_uo]).to_not be_nil - expect(argh[:roof_uo]).to_not be_nil + key = "space_conditioning_category" + val = "Unconditioned" + expect(plnum.additionalProperties.hasFeature(key)).to be false + expect(plnum.additionalProperties.setFeature(key, val)).to be true + expect(TBD.plenum?(plnum)).to be true + expect(TBD.unconditioned?(plnum)).to be true + expect(TBD.setpoints(plnum)[:heating]).to be_nil + expect(TBD.setpoints(plnum)[:cooling]).to be_nil + expect(TBD.status).to be_zero - # 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) + # Generate a new SurfacePropertyOtherSideCoefficients object. + other = OpenStudio::Model::SurfacePropertyOtherSideCoefficients.new(model) + other.setName("other_side_coefficients") + expect(other.setZoneAirTemperatureCoefficient(1)).to be true - # -- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- -- # - # Final attempt, with PSI-factors of 0.09 W/K per linear metre (JSON file). - TBD.clean! + # Reset outside boundary conditions for "open area wall 5" (and plenum wall + # above) by assigning an "OtherSideCoefficients" object (no longer relying + # on "Adiabatic" string). + id1 = "Openarea 1 Wall 5" + s1 = model.getSurfaceByName(id1) + expect(s1).to_not be_empty + s1 = s1.get + expect(s1.setSurfacePropertyOtherSideCoefficients(other)).to be true + expect(s1.outsideBoundaryCondition).to eq("OtherSideCoefficients") - walls = [] - model = translator.loadModel(path) - expect(model).to_not be_empty - model = model.get + id2 = "Level0 Open area 1 Ceiling Plenum AbvClgPlnmWall 5" + s2 = model.getSurfaceByName(id2) + expect(s2).to_not be_empty + s2 = s2.get + expect(s2.setSurfacePropertyOtherSideCoefficients(other)).to be true + expect(s2.outsideBoundaryCondition).to eq("OtherSideCoefficients") - argh = {} - argh[:io_path ] = File.join(__dir__, "../json/tbd_5ZoneNoHVAC.json") - argh[:schema_path ] = File.join(__dir__, "../tbd.schema.json") - argh[:uprate_walls] = true - argh[:uprate_roofs] = true - argh[:wall_option ] = "ALL wall constructions" - argh[:roof_option ] = "ALL roof constructions" - argh[:wall_ut ] = 0.210 # NECB CZ7 2017 (RSi 4.76 / R27) - argh[:roof_ut ] = 0.138 # NECB CZ7 2017 (RSi 7.25 / R41) + argh = {} + argh[:option ] = "compliant" + argh[:io_path ] = File.join(__dir__, "../json/tbd_seb_n8.json") + argh[:schema_path] = File.join(__dir__, "../tbd.schema.json") json = TBD.process(model, argh) expect(json).to be_a(Hash) @@ -7979,1030 +7613,1256 @@ io = json[:io ] surfaces = json[:surfaces] expect(TBD.status).to be_zero + expect(TBD.logs).to be_empty + expect(surfaces).to be_a(Hash) + expect(surfaces.size).to eq(56) + expect(io).to be_a(Hash) + expect(io).to have_key(:edges) + expect(io[:edges].size).to eq(79) - 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].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 + ids = { a: "Entryway Wall 4", + b: "Entryway Wall 5", + c: "Entryway Wall 6", + d: "Entry way DroppedCeiling", + e: "Utility1 Wall 1", + f: "Utility1 Wall 5", + g: "Utility 1 DroppedCeiling", + h: "Smalloffice 1 Wall 1", + i: "Smalloffice 1 Wall 2", + j: "Smalloffice 1 Wall 6", + k: "Small office 1 DroppedCeiling", + l: "Openarea 1 Wall 3", + m: "Openarea 1 Wall 4", + o: "Openarea 1 Wall 6", + p: "Openarea 1 Wall 7", + q: "Open area 1 DroppedCeiling" + }.freeze # removed n: "Openarea 1 Wall 5" - model.getSurfaces.each do |s| - id = s.nameString - next unless s.surfaceType == "Wall" - next unless s.outsideBoundaryCondition == "Outdoors" + surfaces.each do |id, surface| + expect(ids).to have_value(id) if surface.key?(:edges) + expect(ids).to_not have_value(id) unless surface.key?(:edges) + end - walls << id + surfaces.each do |id, surface| + next unless surface.key?(:edges) + expect(ids).to have_value(id) + expect(surface).to have_key(:ratio) + expect(surface).to have_key(:heatloss) + h = surface[:heatloss] + s = model.getSurfaceByName(id) + expect(s).to_not be_empty + s = s.get + expect(s.nameString).to eq(id) expect(s.isConstructionDefaulted).to be false + expect(s.construction.get.nameString).to include(" tbd") + + expect(h).to be_within(TOL).of( 3.62) if id == ids[:a] + expect(h).to be_within(TOL).of( 6.28) if id == ids[:b] + expect(h).to be_within(TOL).of( 2.62) if id == ids[:c] + expect(h).to be_within(TOL).of( 0.17) if id == ids[:d] + expect(h).to be_within(TOL).of( 7.13) if id == ids[:e] + expect(h).to be_within(TOL).of( 7.09) if id == ids[:f] + expect(h).to be_within(TOL).of( 0.20) if id == ids[:g] + expect(h).to be_within(TOL).of( 7.94) if id == ids[:h] + expect(h).to be_within(TOL).of( 5.17) if id == ids[:i] + expect(h).to be_within(TOL).of( 5.01) if id == ids[:j] + expect(h).to be_within(TOL).of( 0.22) if id == ids[:k] + expect(h).to be_within(TOL).of( 2.47) if id == ids[:l] + expect(h).to be_within(TOL).of( 4.03) if id == ids[:m] # 3.11 + expect(h).to be_within(TOL).of( 4.43) if id == ids[:n] + expect(h).to be_within(TOL).of( 4.27) if id == ids[:o] # 3.35 + expect(h).to be_within(TOL).of( 2.12) if id == ids[:p] + expect(h).to be_within(TOL).of( 2.16) if id == ids[:q] # 0.31 + + # The 2x side walls linked to the new party wall "Openarea 1 Wall 5": + # - "Openarea 1 Wall 4", ids[m] + # - "Openarea 1 Wall 6", ids[o] + # ... have 1x half-corner replaced by 100% of a party wall edge, hence + # the increase in extra heat loss. + # + # The "Open area 1 DroppedCeiling", ids[q], has almost a 7x increase in + # extra heat loss. It used to take ~7.6% of the parapet PSI it shared with + # "Wall 5". As the latter is no longer a deratable surface (i.e., a party + # wall), the dropped ceiling hence takes on 100% of the party wall edge + # it still shares with "Wall 5". 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") - - 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].round(3)).to eq(11.205) # R64 - expect(surfaces[wall][:u].round(3)).to eq( 0.086) # R66 + i = 0 + i = 2 if s.outsideBoundaryCondition.downcase == "outdoors" + expect(c.layers[i].nameString).to include("m tbd") end + end - # -- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- -- # - # Realistic, BTAP-costed PSI-factors. + it "can factor in unenclosed space such as attics" do + translator = OpenStudio::OSVersion::VersionTranslator.new TBD.clean! - jpath = "../json/tbd_5ZoneNoHVAC_btap.json" - file = File.join(__dir__, "files/osms/in/5ZoneNoHVAC.osm") + file = File.join(__dir__, "files/osms/in/smalloffice.osm") path = OpenStudio::Path.new(file) model = translator.loadModel(path) expect(model).to_not be_empty model = model.get - # Assign (missing) space types. - north = model.getSpaceByName("Story 1 North Perimeter Space") - east = model.getSpaceByName("Story 1 East Perimeter Space") - south = model.getSpaceByName("Story 1 South Perimeter Space") - west = model.getSpaceByName("Story 1 West Perimeter Space") - core = model.getSpaceByName("Story 1 Core Space") - - expect(north).to_not be_empty - expect(east ).to_not be_empty - expect(south).to_not be_empty - expect(west ).to_not be_empty - expect(core ).to_not be_empty - - north = north.get - east = east.get - south = south.get - west = west.get - core = core.get - - audience = OpenStudio::Model::SpaceType.new(model) - warehouse = OpenStudio::Model::SpaceType.new(model) - offices = OpenStudio::Model::SpaceType.new(model) - sales = OpenStudio::Model::SpaceType.new(model) - workshop = OpenStudio::Model::SpaceType.new(model) - - audience.setName("Audience - auditorium") - warehouse.setName("Warehouse - fine") - offices.setName("Office - enclosed") - sales.setName("Sales area") - workshop.setName("Workshop space") - - expect(north.setSpaceType(audience )).to be true - expect( east.setSpaceType(warehouse)).to be true - expect(south.setSpaceType(offices )).to be true - expect( west.setSpaceType(sales )).to be true - expect( core.setSpaceType(workshop )).to be true - - argh = {} - argh[:io_path ] = File.join(__dir__, jpath) - argh[:schema_path ] = File.join(__dir__, "../tbd.schema.json") - argh[:uprate_walls] = true - argh[:wall_option ] = "ALL wall constructions" - 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) + argh = {} + argh[:option ] = "compliant" # superseded by :building PSI set on file + argh[:io_path ] = File.join(__dir__, "../json/tbd_smalloffice.json") + argh[:schema_path] = File.join(__dir__, "../tbd.schema.json") - # 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 - # (as per NECB requirements). From v3.5.0+, OpenStudio dropped the maximum - # layer thickness limit, harmonizing with EnergyPlus: - # - # https://github.com/NREL/OpenStudio/pull/4622 - # - # This didn't mean EnergyPlus wouldn't halt a simulation due to invalid CTF - # calculations - happens with very thick materials. Recent 2025 TBD changes - # have removed this check. Users of pre-v3.5.X OpenStudio should expect - # OS-generated simulation failures when uprating (extremes cases). Achtung! - expect(argh[:wall_uo].round(4)).to eq(UMIN) # RSi 100 (R568) + 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.status).to be_zero + expect(TBD.logs).to be_empty + expect(surfaces).to be_a(Hash) + expect(surfaces.size).to eq(43) + expect(io).to be_a(Hash) + expect(io).to have_key(:edges) + expect(io[:edges].size).to eq(105) - nb = 0 - m2 = 0 - uA = 0 + # Check derating of attic floor (5x surfaces). + model.getSpaces.each do |space| + next unless space.nameString == "Attic" - model.getSurfaces.each do |s| - next unless s.surfaceType.downcase == "wall" - next unless s.outsideBoundaryCondition.downcase == "outdoors" + expect(space.thermalZone).to_not be_empty + zone = space.thermalZone.get + expect(zone.isPlenum).to be false + expect(zone.canBePlenum).to be true + expect(TBD.plenum?(space)).to be false - c = s.construction - expect(c).to_not be_empty - c = c.get.to_LayeredConstruction - next if c.empty? + space.surfaces.each do |s| + id = s.nameString + expect(surfaces).to have_key(id) + expect(surfaces[id]).to have_key(:space) + next unless surfaces[id][:space].nameString == "Attic" - c = c.get - expect(c.nameString).to include("c tbd") + expect(surfaces[id][:conditioned]).to be false + next if surfaces[id][:boundary] == "outdoors" - lyr = TBD.insulatingLayer(c) - expect(lyr).to be_a(Hash) - expect(lyr).to have_key(:type) - expect(lyr).to have_key(:index) - expect(lyr).to have_key(:r) - expect(lyr[:type]).to eq(:standard) - expect(lyr[:index]).to be_between(0, c.numLayers) - insul = c.getLayer(lyr[:index]) - insul = insul.to_StandardOpaqueMaterial - expect(insul).to_not be_empty - insul = insul.get - expect(insul.thickness.round(3)).to eq(DMAX) # 1m + expect(s.adjacentSurface).to_not be_empty + adjacent = s.adjacentSurface.get.nameString + expect(surfaces).to have_key(adjacent) + expect(surfaces[id][:boundary]).to eq(adjacent) + expect(surfaces[adjacent][:conditioned]).to be true + end + end - r = TBD.rsi(c, s.filmResistance) - m2 += s.netArea - uA += s.netArea / r - nb += 1 + # Check derating of ceilings (below attic). + surfaces.each do |id, surface| + next unless surface.key?(:ratio) + next if surface[:boundary] == "outdoors" + + expect(surface).to have_key(:heatloss) + expect(surface[:heatloss].abs).to be > 0 + expect(id).to include("Perimeter_ZN_") + expect(id).to include("_ceiling") end - ut = uA / m2 - expect((uA/m2).round(2)).to eq(argh[:wall_ut].round(2)) - expect(nb).to eq(4) + # Check derating of outdoor-facing walls. + surfaces.each do |id, surface| + next unless surface.key?(:ratio) + next unless surface[:boundary] == "outdoors" + + expect(surface).to have_key(:heatloss) + expect(surface[:heatloss].abs).to be > 0 + end end - it "can pre-process UA parameters" do + it "can factor in heads, sills and jambs" do translator = OpenStudio::OSVersion::VersionTranslator.new TBD.clean! - ref = "code (Quebec)" - file = File.join(__dir__, "files/osms/in/warehouse.osm") - path = OpenStudio::Path.new(file) + 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 - heated = TBD.heatingTemperatureSetpoints?(model) - cooled = TBD.coolingTemperatureSetpoints?(model) - expect(heated).to be true - expect(cooled).to be true + argh = {} + argh[:option ] = "compliant" # superseded by :building PSI set on file + argh[:io_path ] = File.join(__dir__, "../json/tbd_warehouse7.json") + argh[:schema_path] = File.join(__dir__, "../tbd.schema.json") - model.getSpaces.each do |space| - expect(TBD.unconditioned?(space)).to be false - stpts = TBD.setpoints(space) - expect(stpts).to be_a(Hash) - expect(stpts).to have_key(:heating) - expect(stpts).to have_key(:cooling) + 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.status).to be_zero + expect(TBD.logs).to be_empty + expect(surfaces).to be_a(Hash) + expect(surfaces.size).to eq(23) + expect(io).to be_a(Hash) + expect(io).to have_key(:edges) + expect(io[:edges].size).to eq(300) - heating = stpts[:heating] - cooling = stpts[:cooling] - expect(heating).to be_a(Numeric) - expect(cooling).to be_a(Numeric) + n_transitions = 0 + n_parapets = 0 + n_fen_edges = 0 + n_heads = 0 + n_sills = 0 + n_jambs = 0 + n_skylightheads = 0 + n_skylightsills = 0 + n_skylightjambs = 0 - if space.nameString == "Zone1 Office" - expect(heating).to be_within(0.1).of(21.1) - expect(cooling).to be_within(0.1).of(23.9) - elsif space.nameString == "Zone2 Fine Storage" - expect(heating).to be_within(0.1).of(15.6) - expect(cooling).to be_within(0.1).of(26.7) - else - expect(heating).to be_within(0.1).of(10.0) - expect(cooling).to be_within(0.1).of(50.0) - end - end + types = { + t1: :transition, + t2: :parapetconvex, + t3: :fenestration, + t4: :head, + t5: :sill, + t6: :jamb, + t7: :skylighthead, + t8: :skylightsill, + t9: :skylightjamb + }.freeze - ids = { a: "Office Front Wall", - b: "Office Left Wall", - c: "Fine Storage Roof", - d: "Fine Storage Office Front Wall", - e: "Fine Storage Office Left Wall", - f: "Fine Storage Front Wall", - g: "Fine Storage Left Wall", - h: "Fine Storage Right Wall", - i: "Bulk Storage Roof", - j: "Bulk Storage Rear Wall", - k: "Bulk Storage Left Wall", - l: "Bulk Storage Right Wall" - }.freeze + surfaces.each do |id, surface| + next unless surface[:boundary] == "outdoors" + next unless surface.key?(:ratio) - id2 = { a: "Office Front Door", - b: "Office Left Wall Door", - c: "Fine Storage Left Door", - d: "Fine Storage Right Door", - e: "Bulk Storage Door-1", - f: "Bulk Storage Door-2", - g: "Bulk Storage Door-3", - h: "Overhead Door 1", - i: "Overhead Door 2", - j: "Overhead Door 3", - k: "Overhead Door 4", - l: "Overhead Door 5", - m: "Overhead Door 6", - n: "Overhead Door 7" - }.freeze + expect(surface).to have_key(:heatloss) + expect(surface[:heatloss].abs).to be > 0 + next unless id == "Bulk Storage Roof" - psi = TBD::PSI.new - shrts = psi.shorthands(ref) + expect(surfaces[id]).to have_key(:edges) + expect(surfaces[id][:edges].size).to eq(132) - expect(shrts[:has]).to_not be_empty - expect(shrts[:val]).to_not be_empty - has = shrts[:has] - val = shrts[:val] + surfaces[id][:edges].values.each do |edge| + expect(edge).to have_key(:type) + t = edge[:type] + expect(types.values).to include(t) - expect(has).to_not be_empty - expect(val).to_not be_empty + n_transitions += 1 if edge[:type] == types[:t1] + n_parapets += 1 if edge[:type] == types[:t2] + n_fen_edges += 1 if edge[:type] == types[:t3] + n_heads += 1 if edge[:type] == types[:t4] + n_sills += 1 if edge[:type] == types[:t5] + n_jambs += 1 if edge[:type] == types[:t6] + n_skylightheads += 1 if edge[:type] == types[:t7] + n_skylightsills += 1 if edge[:type] == types[:t8] + n_skylightjambs += 1 if edge[:type] == types[:t9] + end + end - argh = {} - argh[:option ] = "poor (BETBG)" - argh[:seed ] = "./files/osms/in/warehouse.osm" - argh[:io_path ] = File.join(__dir__, "../json/tbd_warehouse10.json") - argh[:schema_path] = File.join(__dir__, "../tbd.schema.json") - argh[:gen_ua ] = true - argh[:ua_ref ] = ref - argh[:version ] = OpenStudio.openStudioVersion + expect(n_transitions ).to eq( 1) + expect(n_parapets ).to eq( 3) + expect(n_fen_edges ).to eq( 0) + expect(n_heads ).to eq( 0) + expect(n_sills ).to eq( 0) + expect(n_jambs ).to eq( 0) + expect(n_skylightheads).to eq( 0) + expect(n_skylightsills).to eq( 0) + expect(n_skylightjambs).to eq(128) + end - TBD.process(model, argh) + it "has a PSI class" do + TBD.clean! + + psi = TBD::PSI.new + expect(psi.set).to have_key("poor (BETBG)") + expect(psi.complete?("poor (BETBG)")).to be true expect(TBD.status).to be_zero expect(TBD.logs).to be_empty - expect(argh).to have_key(:surfaces) - expect(argh).to have_key(:io) - expect(argh[:surfaces]).to be_a(Hash) - expect(argh[:surfaces].size).to eq(23) + expect(psi.set).to_not have_key("new set") + expect(psi.complete?("new set")).to be false + expect(TBD.error?).to be true + expect(TBD.logs.size).to eq(1) + TBD.clean! - expect(argh[:io]).to be_a(Hash) - expect(argh[:io]).to_not be_empty - expect(argh[:io]).to have_key(:edges) - expect(argh[:io][:edges].size).to eq(300) + new_set = { + id: "new set", + rimjoist: 0.000, + parapet: 0.000, + fenestration: 0.000, + cornerconcave: 0.000, + cornerconvex: 0.000, + balcony: 0.000, + party: 0.000, + grade: 0.000 + } - argh[:io][:description] = "test" - # Set up 2x heating setpoint (HSTP) "blocks": - # bloc1: spaces/zones with HSTP >= 18C - # bloc2: spaces/zones with HSTP < 18C - # (ref: 2021 Quebec energy code 3.3. UA' trade-off methodology) - # ... could be generalized in the future e.g., more blocks, user-set HSTP. - # - # Determine UA' compliance separately for (i) bloc1 & (ii) bloc2. - # - # Each block's UA' = ∑ U•area + ∑ PSI•length + ∑ KHI•count - blc = { walls: 0, roofs: 0, floors: 0, doors: 0, - windows: 0, skylights: 0, rimjoists: 0, parapets: 0, - trim: 0, corners: 0, balconies: 0, grade: 0, - other: 0 # includes party wall edges, expansion joints, etc. - } + expect(psi.append(new_set)).to be true + expect(psi.set).to have_key("new set") + expect(psi.complete?("new set")).to be true + expect(TBD.status).to be_zero + expect(TBD.logs).to be_empty + + expect(psi.set["new set"][:grade].to_i).to be_zero + new_set[:grade] = 1.0 + expect(psi.append(new_set)).to be false # does not override existing value + expect(TBD.error?).to be true + expect(TBD.logs.size).to eq(1) + expect(psi.set["new set"][:grade].to_i).to be_zero + expect(psi.set).to_not have_key("incomplete set") + expect(psi.complete?("incomplete set")).to be false + + incomplete_set = { + id: "incomplete set", + grade: 0.000 + }.freeze - bloc1 = {} - bloc2 = {} - bloc1[:pro] = blc - bloc1[:ref] = blc.clone - bloc2[:pro] = blc.clone - bloc2[:ref] = blc.clone + expect(psi.append(incomplete_set)).to be true + expect(psi.set).to have_key("incomplete set") + expect(psi.complete?("incomplete set")).to be false + expect(psi.set).to_not have_key("all sills") - argh[:surfaces].each do |id, surface| - expect(surface).to have_key(:deratable) - next unless surface[:deratable] + all_sills = { + id: "all sills", + fenestration: 0.391, + head: 0.381, + headconcave: 0.382, + headconvex: 0.383, + sill: 0.371, + sillconcave: 0.372, + sillconvex: 0.373, + jamb: 0.361, + jambconcave: 0.362, + jambconvex: 0.363, + rimjoist: 0.001, + parapet: 0.002, + corner: 0.003, + balcony: 0.004, + party: 0.005, + grade: 0.006 + }.freeze - expect(ids).to have_value(id) - expect(surface).to have_key(:type) - expect(surface).to have_key(:net ) - expect(surface).to have_key(:u) + expect(psi.append(all_sills)).to be true + expect(psi.set).to have_key("all sills") + expect(psi.complete?("all sills")).to be true - expect(surface[:net] > TOL).to be true - expect(surface[:u ] > TOL).to be true + shorts = psi.shorthands("all sills") + expect(shorts[:has]).to_not be_empty + expect(shorts[:val]).to_not be_empty - expect(surface[:u]).to be_within(TOL).of(0.48) if id == ids[:a] - expect(surface[:u]).to be_within(TOL).of(0.48) if id == ids[:b] - expect(surface[:u]).to be_within(TOL).of(0.31) if id == ids[:c] - expect(surface[:u]).to be_within(TOL).of(0.48) if id == ids[:d] - expect(surface[:u]).to be_within(TOL).of(0.48) if id == ids[:e] - expect(surface[:u]).to be_within(TOL).of(0.48) if id == ids[:f] - expect(surface[:u]).to be_within(TOL).of(0.48) if id == ids[:g] - expect(surface[:u]).to be_within(TOL).of(0.48) if id == ids[:h] - expect(surface[:u]).to be_within(TOL).of(0.55) if id == ids[:i] - expect(surface[:u]).to be_within(TOL).of(0.64) if id == ids[:j] - expect(surface[:u]).to be_within(TOL).of(0.64) if id == ids[:k] - expect(surface[:u]).to be_within(TOL).of(0.64) if id == ids[:l] + holds = shorts[:has] + vals = shorts[:val] + expect(holds[:fenestration]).to be true + expect(vals[:sill ]).to be_within(0.001).of(0.371) + expect(vals[:sillconcave]).to be_within(0.001).of(0.372) + expect(vals[:sillconvex ]).to be_within(0.001).of(0.373) + expect(psi.set).to_not have_key("partial sills") - # Reference values. - expect(surface).to have_key(:ref) + partial_sills = { + id: "partial sills", + fenestration: 0.391, + head: 0.381, + headconcave: 0.382, + headconvex: 0.383, + sill: 0.371, + sillconcave: 0.372, + # sillconvex: 0.373, # dropping the convex variant + jamb: 0.361, + jambconcave: 0.362, + jambconvex: 0.363, + rimjoist: 0.001, + parapet: 0.002, + corner: 0.003, + balcony: 0.004, + party: 0.005, + grade: 0.006 + }.freeze - expect(surface[:ref]).to be_within(TOL).of(0.28) if id == ids[:a] - expect(surface[:ref]).to be_within(TOL).of(0.28) if id == ids[:b] - expect(surface[:ref]).to be_within(TOL).of(0.18) if id == ids[:c] - expect(surface[:ref]).to be_within(TOL).of(0.28) if id == ids[:d] - expect(surface[:ref]).to be_within(TOL).of(0.28) if id == ids[:e] - expect(surface[:ref]).to be_within(TOL).of(0.28) if id == ids[:f] - expect(surface[:ref]).to be_within(TOL).of(0.28) if id == ids[:g] - expect(surface[:ref]).to be_within(TOL).of(0.28) if id == ids[:h] - expect(surface[:ref]).to be_within(TOL).of(0.23) if id == ids[:i] - expect(surface[:ref]).to be_within(TOL).of(0.34) if id == ids[:j] - expect(surface[:ref]).to be_within(TOL).of(0.34) if id == ids[:k] - expect(surface[:ref]).to be_within(TOL).of(0.34) if id == ids[:l] + expect(psi.append(partial_sills)).to be true + expect(psi.set).to have_key("partial sills") + expect(psi.complete?("partial sills")).to be true # can be a building set + shorts = psi.shorthands("partial sills") + expect(shorts[:has]).to_not be_empty + expect(shorts[:val]).to_not be_empty - expect(surface).to have_key(:heating) - expect(surface).to have_key(:cooling) - bloc = bloc1 - bloc = bloc2 if surface[:heating] < 18 + holds = shorts[:has] + vals = shorts[:val] + expect(holds[:sillconvex]).to be false # absent from PSI set + expect(vals[:sill ]).to be_within(0.001).of(0.371) + expect(vals[:sillconcave]).to be_within(0.001).of(0.372) + expect(vals[:sillconvex ]).to be_within(0.001).of(0.371) # inherits :sill + expect(psi.set).to_not have_key("no sills") - if surface[:type ] == :wall - bloc[:pro][:walls ] += surface[:net] * surface[:u ] - bloc[:ref][:walls ] += surface[:net] * surface[:ref] - elsif surface[:type ] == :ceiling - bloc[:pro][:roofs ] += surface[:net] * surface[:u ] - bloc[:ref][:roofs ] += surface[:net] * surface[:ref] - else - bloc[:pro][:floors] += surface[:net] * surface[:u ] - bloc[:ref][:floors] += surface[:net] * surface[:ref] - end + no_sills = { + id: "no sills", + fenestration: 0.391, + head: 0.381, + headconcave: 0.382, + headconvex: 0.383, + # sill: 0.371, # dropping the concave variant + # sillconcave: 0.372, # dropping the concave variant + # sillconvex: 0.373, # dropping the convex variant + jamb: 0.361, + jambconcave: 0.362, + jambconvex: 0.363, + rimjoist: 0.001, + parapet: 0.002, + corner: 0.003, + balcony: 0.004, + party: 0.005, + grade: 0.006 + }.freeze - if surface.key?(:doors) - surface[:doors].each do |i, door| - expect(id2).to have_value(i) - expect(door).to_not have_key(:glazed) - expect(door).to have_key(:gross ) - expect(door).to have_key(:u) - expect(door).to have_key(:ref) - expect(door[:gross] > TOL).to be true - expect(door[:ref ] > TOL).to be true - expect(door[:u ] > TOL).to be true - expect(door[:u ]).to be_within(TOL).of(3.98) - bloc[:pro][:doors] += door[:gross] * door[:u ] - bloc[:ref][:doors] += door[:gross] * door[:ref] - end - end + expect(psi.append(no_sills)).to be true + expect(psi.set).to have_key("no sills") + expect(psi.complete?("no sills")).to be true # can be a building set + shorts = psi.shorthands("no sills") + expect(shorts[:has]).to_not be_empty + expect(shorts[:val]).to_not be_empty - if surface.key?(:skylights) - surface[:skylights].each do |i, skylight| - expect(skylight).to have_key(:gross) - expect(skylight).to have_key(:u) - expect(skylight).to have_key(:ref) - expect(skylight[:gross] > TOL).to be true - expect(skylight[:ref ] > TOL).to be true - expect(skylight[:u ] > TOL).to be true - expect(skylight[:u ]).to be_within(TOL).of(6.64) - bloc[:pro][:skylights] += skylight[:gross] * skylight[:u ] - bloc[:ref][:skylights] += skylight[:gross] * skylight[:ref] - end - end + holds = shorts[:has] + vals = shorts[:val] + expect(holds[:sill ]).to be false # absent from PSI set + expect(holds[:sillconcave]).to be false # absent from PSI set + expect(holds[:sillconvex ]).to be false # absent from PSI set + expect(vals[:sill ]).to be_within(0.001).of(0.391) + expect(vals[:sillconcave ]).to be_within(0.001).of(0.391) + expect(vals[:sillconvex ]).to be_within(0.001).of(0.391) # :fenestration + end - id3 = { a: "Office Front Wall Window 1", - b: "Office Front Wall Window2" - }.freeze + it "can factor-in Frame & Divider (F&D) objects" do + translator = OpenStudio::OSVersion::VersionTranslator.new + version = OpenStudio.openStudioVersion.split(".").join.to_i + TBD.clean! - if surface.key?(:windows) - surface[:windows].each do |i, window| - expect(window).to have_key(:u) - expect(window).to have_key(:ref) - expect(window[:ref] > TOL).to be true + model = OpenStudio::Model::Model.new + vec = OpenStudio::Point3dVector.new + vec << OpenStudio::Point3d.new( 2.00, 0.00, 3.00) + vec << OpenStudio::Point3d.new( 2.00, 0.00, 1.00) + vec << OpenStudio::Point3d.new( 4.00, 0.00, 1.00) + vec << OpenStudio::Point3d.new( 4.00, 0.00, 3.00) + sub = OpenStudio::Model::SubSurface.new(vec, model) - bloc[:pro][:windows] += window[:gross] * window[:u ] - bloc[:ref][:windows] += window[:gross] * window[:ref] + # Aide-mémoire: attributes/objects subsurfaces are allowed to have/be. + OpenStudio::Model::SubSurface.validSubSurfaceTypeValues.each do |type| + expect(sub.setSubSurfaceType(type)).to be true + # FixedWindow + # OperableWindow + # Door + # GlassDoor + # OverheadDoor + # Skylight + # TubularDaylightDome + # TubularDaylightDiffuser + case type + when "FixedWindow" + expect(sub.allowWindowPropertyFrameAndDivider ).to be true + next if version < 330 - expect(window[:u ] > 0).to be true - expect(window[:u ]).to be_within(TOL).of(4.00) if i == id3[:a] - expect(window[:u ]).to be_within(TOL).of(3.50) if i == id3[:b] - expect(window[:gross]).to be_within(TOL).of(5.58) if i == id3[:a] - expect(window[:gross]).to be_within(TOL).of(5.58) if i == id3[:b] + expect(sub.allowDaylightingDeviceTubularDiffuser).to be false + expect(sub.allowDaylightingDeviceTubularDome ).to be false + when "OperableWindow" + expect(sub.allowWindowPropertyFrameAndDivider ).to be true + next if version < 330 - next if [id3[:a], id3[:b]].include?(i) + expect(sub.allowDaylightingDeviceTubularDiffuser).to be false + expect(sub.allowDaylightingDeviceTubularDome ).to be false + when "Door" + expect(sub.allowWindowPropertyFrameAndDivider ).to be false + next if version < 330 - expect(window[:gross]).to be_within(TOL).of(3.25) - expect(window[:u ]).to be_within(TOL).of(2.35) + expect(sub.allowDaylightingDeviceTubularDiffuser).to be false + expect(sub.allowDaylightingDeviceTubularDome ).to be false + when "GlassDoor" + expect(sub.allowWindowPropertyFrameAndDivider ).to be true + next if version < 330 + + expect(sub.allowDaylightingDeviceTubularDiffuser).to be false + expect(sub.allowDaylightingDeviceTubularDome ).to be false + when "OverheadDoor" + expect(sub.allowWindowPropertyFrameAndDivider ).to be false + next if version < 330 + + expect(sub.allowDaylightingDeviceTubularDiffuser).to be false + expect(sub.allowDaylightingDeviceTubularDome ).to be false + when "Skylight" + if version < 321 + expect(sub.allowWindowPropertyFrameAndDivider ).to be false + else + expect(sub.allowWindowPropertyFrameAndDivider ).to be true end - end - - if surface.key?(:edges) - surface[:edges].values.each do |edge| - expect(edge).to have_key(:type ) - expect(edge).to have_key(:ratio) - expect(edge).to have_key(:ref ) - expect(edge).to have_key(:psi ) - next unless edge[:psi] > TOL - tt = psi.safe(ref, edge[:type]) - expect(tt).to_not be_nil + next if version < 330 - expect(edge[:ref]).to be_within(TOL).of(val[tt] * edge[:ratio]) - rate = edge[:ref] / edge[:psi] * 100 + expect(sub.allowDaylightingDeviceTubularDiffuser).to be false + expect(sub.allowDaylightingDeviceTubularDome ).to be false + when "TubularDaylightDome" + expect(sub.allowWindowPropertyFrameAndDivider ).to be false + next if version < 330 - case tt - when :rimjoist - expect(rate).to be_within(0.1).of(30.0) - bloc[:pro][:rimjoists] += edge[:length] * edge[:psi ] - bloc[:ref][:rimjoists] += edge[:length] * edge[:ratio] * val[tt] - when :parapet - expect(rate).to be_within(0.1).of(40.6) - bloc[:pro][:parapets ] += edge[:length] * edge[:psi ] - bloc[:ref][:parapets ] += edge[:length] * edge[:ratio] * val[tt] - when :fenestration - expect(rate).to be_within(0.1).of(40.0) - bloc[:pro][:trim ] += edge[:length] * edge[:psi ] - bloc[:ref][:trim ] += edge[:length] * edge[:ratio] * val[tt] - when :door - expect(rate).to be_within(0.1).of(40.0) - bloc[:pro][:trim ] += edge[:length] * edge[:psi ] - bloc[:ref][:trim ] += edge[:length] * edge[:ratio] * val[tt] - when :skylight - expect(rate).to be_within(0.1).of(40.0) - bloc[:pro][:trim ] += edge[:length] * edge[:psi ] - bloc[:ref][:trim ] += edge[:length] * edge[:ratio] * val[tt] - when :corner - expect(rate).to be_within(0.1).of(35.3) - bloc[:pro][:corners ] += edge[:length] * edge[:psi ] - bloc[:ref][:corners ] += edge[:length] * edge[:ratio] * val[tt] - when :grade - expect(rate).to be_within(0.1).of(52.9) - bloc[:pro][:grade ] += edge[:length] * edge[:psi ] - bloc[:ref][:grade ] += edge[:length] * edge[:ratio] * val[tt] - else - expect(rate).to be_within(0.1).of( 0.0) - bloc[:pro][:other ] += edge[:length] * edge[:psi ] - bloc[:ref][:other ] += edge[:length] * edge[:ratio] * val[tt] - end - end - end + expect(sub.allowDaylightingDeviceTubularDiffuser).to be false + expect(sub.allowDaylightingDeviceTubularDome ).to be true + when "TubularDaylightDiffuser" + expect(sub.allowWindowPropertyFrameAndDivider ).to be false + next if version < 330 - if surface.key?(:pts) - surface[:pts].values.each do |pts| - expect(pts).to have_key(:val) - expect(pts).to have_key(:n) - expect(pts).to have_key(:ref) - bloc[:pro][:other] += pts[:val] * pts[:n] - bloc[:ref][:other] += pts[:ref] * pts[:n] - end + expect(sub.allowDaylightingDeviceTubularDiffuser).to be true + expect(sub.allowDaylightingDeviceTubularDome ).to be false + else + expect(true).to be false # Unknown SubSurfaceType! end end - expect(bloc1[:pro][:walls ]).to be_within(0.1).of( 60.1) - expect(bloc1[:pro][:roofs ]).to be_within(0.1).of( 0.0) - expect(bloc1[:pro][:floors ]).to be_within(0.1).of( 0.0) - expect(bloc1[:pro][:doors ]).to be_within(0.1).of( 23.3) - expect(bloc1[:pro][:windows ]).to be_within(0.1).of( 57.1) - expect(bloc1[:pro][:skylights]).to be_within(0.1).of( 0.0) - expect(bloc1[:pro][:rimjoists]).to be_within(0.1).of( 17.5) - expect(bloc1[:pro][:parapets ]).to be_within(0.1).of( 0.0) - expect(bloc1[:pro][:trim ]).to be_within(0.1).of( 23.3) - expect(bloc1[:pro][:corners ]).to be_within(0.1).of( 3.6) - expect(bloc1[:pro][:balconies]).to be_within(0.1).of( 0.0) - expect(bloc1[:pro][:grade ]).to be_within(0.1).of( 29.8) - expect(bloc1[:pro][:other ]).to be_within(0.1).of( 0.0) + 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 - bloc1_pro_UA = bloc1[:pro].values.sum - bloc1_ref_UA = bloc1[:ref].values.sum - bloc2_pro_UA = bloc2[:pro].values.sum - bloc2_ref_UA = bloc2[:ref].values.sum + wll = "Office Front Wall" + win = "Office Front Wall Window 1" - expect(bloc1_pro_UA).to be_within(0.1).of( 214.8) - expect(bloc1_ref_UA).to be_within(0.1).of( 107.2) - expect(bloc2_pro_UA).to be_within(0.1).of(4863.6) - expect(bloc2_ref_UA).to be_within(0.1).of(2275.4) + front = model.getSurfaceByName(wll) + expect(front).to_not be_empty + front = front.get - expect(bloc1[:ref][:walls ]).to be_within(0.1).of( 35.0) - expect(bloc1[:ref][:roofs ]).to be_within(0.1).of( 0.0) - expect(bloc1[:ref][:floors ]).to be_within(0.1).of( 0.0) - expect(bloc1[:ref][:doors ]).to be_within(0.1).of( 5.3) - expect(bloc1[:ref][:windows ]).to be_within(0.1).of( 35.3) - expect(bloc1[:ref][:skylights]).to be_within(0.1).of( 0.0) - expect(bloc1[:ref][:rimjoists]).to be_within(0.1).of( 5.3) - expect(bloc1[:ref][:parapets ]).to be_within(0.1).of( 0.0) - expect(bloc1[:ref][:trim ]).to be_within(0.1).of( 9.3) - expect(bloc1[:ref][:corners ]).to be_within(0.1).of( 1.3) - expect(bloc1[:ref][:balconies]).to be_within(0.1).of( 0.0) - expect(bloc1[:ref][:grade ]).to be_within(0.1).of( 15.8) - expect(bloc1[:ref][:other ]).to be_within(0.1).of( 0.0) + argh = {} + argh[:option ] = "poor (BETBG)" + argh[:io_path ] = File.join(__dir__, "../json/tbd_warehouse8.json") + argh[:schema_path] = File.join(__dir__, "../tbd.schema.json") - expect(bloc2[:pro][:walls ]).to be_within(0.1).of(1342.0) - expect(bloc2[:pro][:roofs ]).to be_within(0.1).of(2169.2) - expect(bloc2[:pro][:floors ]).to be_within(0.1).of( 0.0) - expect(bloc2[:pro][:doors ]).to be_within(0.1).of( 245.6) - expect(bloc2[:pro][:windows ]).to be_within(0.1).of( 0.0) - expect(bloc2[:pro][:skylights]).to be_within(0.1).of( 454.3) - expect(bloc2[:pro][:rimjoists]).to be_within(0.1).of( 17.5) - expect(bloc2[:pro][:parapets ]).to be_within(0.1).of( 234.1) - expect(bloc2[:pro][:trim ]).to be_within(0.1).of( 155.0) - expect(bloc2[:pro][:corners ]).to be_within(0.1).of( 25.4) - expect(bloc2[:pro][:balconies]).to be_within(0.1).of( 0.0) - expect(bloc2[:pro][:grade ]).to be_within(0.1).of( 218.9) - expect(bloc2[:pro][:other ]).to be_within(0.1).of( 1.6) + 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.status).to be_zero + expect(TBD.logs).to be_empty + expect(surfaces).to be_a(Hash) + expect(surfaces.size).to eq(23) + expect(io).to be_a(Hash) + expect(io).to have_key(:edges) + expect(io[:edges].size).to eq(300) - expect(bloc2[:ref][:walls ]).to be_within(0.1).of( 732.0) - expect(bloc2[:ref][:roofs ]).to be_within(0.1).of( 961.8) - expect(bloc2[:ref][:floors ]).to be_within(0.1).of( 0.0) - expect(bloc2[:ref][:doors ]).to be_within(0.1).of( 67.5) - expect(bloc2[:ref][:windows ]).to be_within(0.1).of( 0.0) - expect(bloc2[:ref][:skylights]).to be_within(0.1).of( 225.9) - expect(bloc2[:ref][:rimjoists]).to be_within(0.1).of( 5.3) - expect(bloc2[:ref][:parapets ]).to be_within(0.1).of( 95.1) - expect(bloc2[:ref][:trim ]).to be_within(0.1).of( 62.0) - expect(bloc2[:ref][:corners ]).to be_within(0.1).of( 9.0) - expect(bloc2[:ref][:balconies]).to be_within(0.1).of( 0.0) - expect(bloc2[:ref][:grade ]).to be_within(0.1).of( 115.9) - expect(bloc2[:ref][:other ]).to be_within(0.1).of( 1.0) + n_transitions = 0 + n_fen_edges = 0 + n_heads = 0 + n_sills = 0 + n_jambs = 0 + n_doorheads = 0 + n_doorsills = 0 + n_doorjambs = 0 + n_grades = 0 + n_corners = 0 + n_rimjoists = 0 + fen_length = 0 - # Testing summaries function. - ua = TBD.ua_summary(Time.now, argh) - expect(ua).to_not be_nil - expect(ua).to_not be_empty - expect(ua).to be_a(Hash) - expect(ua).to have_key(:model) - expect(ua).to have_key(:fr) + t1 = :transition + t2 = :fenestration + t3 = :head + t4 = :sill + t5 = :jamb + t6 = :doorhead + t7 = :doorsill + t8 = :doorjamb + t9 = :gradeconvex + t10 = :cornerconvex + t11 = :rimjoist - expect(ua[:fr]).to have_key(:objective) - expect(ua[:fr]).to have_key(:details) - expect(ua[:fr]).to have_key(:areas) - expect(ua[:fr]).to have_key(:notes) + surfaces.each do |id, surface| + next unless surface[:boundary] == "outdoors" + next unless surface.key?(:ratio) - expect(ua[:fr][:objective]).to_not be_empty + expect(surface).to have_key(:heatloss) + expect(surface[:heatloss].abs).to be > 0 + next unless id == wll - expect(ua[:fr][:details]).to be_a(Array) - expect(ua[:fr][:details]).to_not be_empty + expect(surface[:heatloss]).to be_within(0.1).of(50.2) + expect(surface).to have_key(:edges) + expect(surface[:edges].size).to eq(17) - expect(ua[:fr][:areas]).to be_a(Hash) - expect(ua[:fr][:areas]).to_not be_empty - expect(ua[:fr][:areas]).to have_key(:walls) - expect(ua[:fr][:areas]).to have_key(:roofs) - expect(ua[:fr][:areas]).to_not have_key(:floors) - expect(ua[:fr][:notes]).to_not be_empty + surface[:edges].values.each do |edge| + expect(edge).to have_key(:type) - expect(ua[:fr]).to have_key(:b1) - expect(ua[:fr][:b1]).to_not be_empty - expect(ua[:fr][:b1]).to have_key(:summary) - expect(ua[:fr][:b1]).to have_key(:walls) - expect(ua[:fr][:b1]).to have_key(:doors) - expect(ua[:fr][:b1]).to have_key(:windows) - expect(ua[:fr][:b1]).to have_key(:rimjoists) - expect(ua[:fr][:b1]).to have_key(:trim) - expect(ua[:fr][:b1]).to have_key(:corners) - expect(ua[:fr][:b1]).to have_key(:grade) - expect(ua[:fr][:b1]).to_not have_key(:roofs) - expect(ua[:fr][:b1]).to_not have_key(:floors) - expect(ua[:fr][:b1]).to_not have_key(:skylights) - expect(ua[:fr][:b1]).to_not have_key(:parapets) - expect(ua[:fr][:b1]).to_not have_key(:balconies) - expect(ua[:fr][:b1]).to_not have_key(:other) + n_transitions += 1 if edge[:type] == t1 + n_fen_edges += 1 if edge[:type] == t2 + n_heads += 1 if edge[:type] == t3 + n_sills += 1 if edge[:type] == t4 + n_jambs += 1 if edge[:type] == t5 + n_doorheads += 1 if edge[:type] == t6 + n_doorsills += 1 if edge[:type] == t7 + n_doorjambs += 1 if edge[:type] == t8 + n_grades += 1 if edge[:type] == t9 + n_corners += 1 if edge[:type] == t10 + n_rimjoists += 1 if edge[:type] == t11 + + fen_length += edge[:length] if edge[:type] == t2 + end + end + + expect(n_transitions).to eq(1) + expect(n_fen_edges ).to eq(4) # Office Front Wall Window 1 + expect(n_heads ).to eq(1) # Window 2 + expect(n_sills ).to eq(1) # Window 2 + expect(n_jambs ).to eq(2) # Window 2 + expect(n_doorheads ).to eq(1) # door + expect(n_doorsills ).to eq(0) # grade PSI > fenestration PSI + expect(n_doorjambs ).to eq(2) # door + expect(n_grades ).to eq(3) # including door sill + expect(n_corners ).to eq(1) + expect(n_rimjoists ).to eq(1) - expect(ua[:fr]).to have_key(:b2) - expect(ua[:fr][:b2]).to_not be_empty - expect(ua[:fr][:b2]).to have_key(:summary) - expect(ua[:fr][:b2]).to have_key(:walls) - expect(ua[:fr][:b2]).to have_key(:roofs) - expect(ua[:fr][:b2]).to have_key(:doors) - expect(ua[:fr][:b2]).to have_key(:skylights) - expect(ua[:fr][:b2]).to have_key(:rimjoists) - expect(ua[:fr][:b2]).to have_key(:parapets) - expect(ua[:fr][:b2]).to have_key(:trim) - expect(ua[:fr][:b2]).to have_key(:corners) - expect(ua[:fr][:b2]).to have_key(:grade) - expect(ua[:fr][:b2]).to have_key(:other) - expect(ua[:fr][:b2]).to_not have_key(:floors) - expect(ua[:fr][:b2]).to_not have_key(:windows) - expect(ua[:fr][:b2]).to_not have_key(:balconies) + # Net & gross areas, as well as fenestration perimeters, reflect cases + # without frame & divider objects. This is also what would be reported by + # SketchUp, for instance. + expect(fen_length ).to be_within(TOL).of( 10.36) # Window 1 perimeter + expect(front.netArea ).to be_within(TOL).of( 95.49) + expect(front.grossArea).to be_within(TOL).of(110.54) - expect(ua[:en]).to have_key(:b1) - expect(ua[:en][:b1]).to_not be_empty - expect(ua[:en][:b1]).to have_key(:summary) - expect(ua[:en][:b1]).to have_key(:walls) - expect(ua[:en][:b1]).to have_key(:doors) - expect(ua[:en][:b1]).to have_key(:windows) - expect(ua[:en][:b1]).to have_key(:rimjoists) - expect(ua[:en][:b1]).to have_key(:trim) - expect(ua[:en][:b1]).to have_key(:corners) - expect(ua[:en][:b1]).to have_key(:grade) - expect(ua[:en][:b1]).to_not have_key(:roofs) - expect(ua[:en][:b1]).to_not have_key(:floors) - expect(ua[:en][:b1]).to_not have_key(:skylights) - expect(ua[:en][:b1]).to_not have_key(:parapets ) - expect(ua[:en][:b1]).to_not have_key(:balconies) - expect(ua[:en][:b1]).to_not have_key(:other) - expect(ua[:en]).to have_key(:b2) - expect(ua[:en][:b2]).to_not be_empty - expect(ua[:en][:b2]).to have_key(:summary) - expect(ua[:en][:b2]).to have_key(:walls) - expect(ua[:en][:b2]).to have_key(:roofs) - expect(ua[:en][:b2]).to have_key(:doors) - expect(ua[:en][:b2]).to have_key(:skylights) - expect(ua[:en][:b2]).to have_key(:rimjoists) - expect(ua[:en][:b2]).to have_key(:parapets) - expect(ua[:en][:b2]).to have_key(:trim) - expect(ua[:en][:b2]).to have_key(:corners) - expect(ua[:en][:b2]).to have_key(:grade) - expect(ua[:en][:b2]).to have_key(:other) - expect(ua[:en][:b2]).to_not have_key(:floors) - expect(ua[:en][:b2]).to_not have_key(:windows) - expect(ua[:en][:b2]).to_not have_key(:balconies) + # Open another warehouse model and add/assign a Frame & Divider object. + file = File.join(__dir__, "files/osms/in/warehouse.osm") + path = OpenStudio::Path.new(file) + model_FD = translator.loadModel(path) + expect(model_FD).to_not be_empty + model_FD = model_FD.get - ud_md_en = TBD.ua_md(ua, :en) - ud_md_fr = TBD.ua_md(ua, :fr) - path_en = File.join(__dir__, "files/ua/ua_en.md") - path_fr = File.join(__dir__, "files/ua/ua_fr.md") + # Adding/validating Frame & Divider object. + fd = OpenStudio::Model::WindowPropertyFrameAndDivider.new(model_FD) + width = 0.03 + expect(fd.setFrameWidth(width)).to be true # 30mm (narrow) around glazing + expect(fd.setFrameConductance(2.500)).to be true - File.open(path_en, "w") { |file| file.puts ud_md_en } - File.open(path_fr, "w") { |file| file.puts ud_md_fr } + window_FD = model_FD.getSubSurfaceByName(win) + expect(window_FD).to_not be_empty + window_FD = window_FD.get - # Try with an incomplete reference, e.g. (non thermal bridging). - TBD.clean! + expect(window_FD.allowWindowPropertyFrameAndDivider).to be true + expect(window_FD.setWindowPropertyFrameAndDivider(fd)).to be true + width2 = window_FD.windowPropertyFrameAndDivider.get.frameWidth + expect(width2).to be_within(TOL).of(width) - 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 + front_FD = model_FD.getSurfaceByName(wll) + expect(front_FD).to_not be_empty + front_FD = front_FD.get - # When faced with an edge that may be characterized by more than one thermal - # bridge type (e.g. ground-floor door "sill" vs "grade" edge; "corner" vs - # corner window "jamb"), TBD retains the edge type (amongst candidate edge - # types) representing the greatest heat loss: - # - # psi = edge[:psi].values.max - # type = edge[:psi].key(psi) - # - # As long as there is a slight difference in PSI-factors between candidate - # edge types, the automated selection will be deterministic. With 2 or more - # edge types sharing the exact same PSI-factor (e.g. 0.3 W/K per m), the - # final edge type selection becomes less obvious. It is not randomly - # selected, but rather based on the (somewhat arbitrary) design choice of - # which edge type is processed first in psi.rb (line ~1300 onwards). For - # instance, fenestration perimeters are treated before corners or parapets. - # When dealing with equal hash values, Ruby's Hash "key" method - # returns the first key (i.e. edge type) that matches the criterion: + expect(window_FD.netArea ).to be_within(TOL).of( 5.58) + expect(window_FD.grossArea).to be_within(TOL).of( 5.58) # not 5.89 (OK) + expect(front_FD.grossArea ).to be_within(TOL).of(110.54) # this is OK + + unless version < 340 + # As of v3.4.0, SDK-reported WWR ratio calculations will ignore added + # frame areas if associated subsurfaces no longer 'fit' within the + # parent surface polygon, or overlap any of their siblings. For v340 and + # up, one can only rely on SDK-reported WWR to safely determine TRUE net + # area for a parent surface. + # + # For older SDK versions, TBD/OSut methods are required to do the same. + # + # https://github.com/NREL/OpenStudio/issues/4361 + # + # Here, the parent wall net area reflects the added (valid) frame areas. + # However, this net area reports erroneous values when F&D objects + # 'conflict', e.g. they don't fit in, or they overlap their siblings. + expect(window_FD.roughOpeningArea).to be_within(TOL).of( 5.89) + expect(front_FD.netArea ).to be_within(TOL).of(95.17) # great !! + expect(front_FD.windowToWallRatio).to be_within(TOL).of(0.104) # !! + else + expect(front_FD.netArea ).to be_within(TOL).of(95.49) # !95.17 + expect(front_FD.windowToWallRatio).to be_within(TOL).of(0.101) # !0.104 + end + + # If one runs an OpenStudio +v3.4 simulation with the exported file below + # ("model_FD.osm"), EnergyPlus will correctly report (e.g. eplustbl.htm) + # a building WWR (gross window-wall ratio) of 72% (vs 71% without F&D), due + # to the slight increase in area of the "Office Front Wall Window 1" (from + # 5.58 m2 to 5.89 m2). The report clearly distinguishes between the revised + # glazing area of 5.58 m2 vs a new framing area of 0.31 m2 for this window. + # Finally, the parent surface "Office Front Wall" area will also be + # correctly reported as 95.17 m2 (vs 95.49 m2). So OpenStudio is correctly + # forward translating the subsurface and linked Frame & Divider objects to + # EnergyPlus (triangular subsurfaces not tested). # - # https://docs.ruby-lang.org/en/2.0.0/Hash.html#method-i-key + # For prior versions to v3.4, there are discrepencies between the net area + # of the "Office Front Wall" reported by the OpenStudio API vs EnergyPlus. + # This may seem minor when looking at the numbers above, but keep in mind a + # single glazed subsurface is modified for this comparison. This difference + # could easily reach 5% to 10% for models with many windows, especially + # those with narrow aspect ratios (lots of framing). # - # From an energy simulation results perspective, the consequences of this - # pseudo-random choice are insignificant (i.e. ~same PSI-factor). For UA' - # comparisons, the situation becomes less obvious in outlier cases. When a - # reference value needs to be generated for a given edge, TBD retains the - # original autoselected edge type, yet applies reference PSI values (e.g. - # "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 - # been added to TBD built-in PSI-factor sets (where required). Without this - # fix, undesirable variations in reference UA' tallies may occur. + # ... subsurface.netArea calculation here could be reconsidered : # - # This overview remains an "aide-mémoire" for future guide material. - argh[:io ] = nil - argh[:surfaces ] = nil - argh[:option ] = "(non thermal bridging)" - argh[:io_path ] = nil - argh[:schema_path] = nil - argh[:gen_ua ] = true - argh[:ua_ref ] = ref + # https://github.com/NREL/OpenStudio/blob/ + # 70a5549c439eda69d6c514a7275254f71f7e3d2b/src/model/Surface.cpp#L1446 + pth = File.join(__dir__, "files/osms/out/model_FD.osm") + model_FD.save(pth, true) - TBD.process(model, argh) - expect(TBD.status).to be_zero + argh = {} + argh[:option ] = "poor (BETBG)" + argh[:io_path ] = File.join(__dir__, "../json/tbd_warehouse8.json") + argh[:schema_path] = File.join(__dir__, "../tbd.schema.json") + + json = TBD.process(model_FD, 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.status.zero?).to eq(true) expect(TBD.logs).to be_empty - expect(argh).to have_key(:surfaces) - expect(argh).to have_key(:io) + expect(surfaces).to be_a(Hash) + expect(surfaces.size).to eq(23) + expect(io).to be_a(Hash) + expect(io).to have_key(:edges) + expect(io[:edges].size).to eq(300) - expect(argh[:surfaces]).to be_a(Hash) - expect(argh[:surfaces].size).to eq(23) + # TBD calling on workarounds. + net_area = surfaces[wll][:net ] + gross_area = surfaces[wll][:gross] + expect(net_area ).to be_within(TOL).of( 95.17) # ! API 95.49 + expect(gross_area).to be_within(TOL).of(110.54) # same + expect(surfaces[wll]).to have_key(:windows) + expect(surfaces[wll][:windows].size).to eq(2) - expect(argh[:io]).to be_a(Hash) - expect(argh[:io]).to have_key(:edges) - expect(argh[:io][:edges].size).to eq(300) + surfaces[wll][:windows].each do |i, window| + expect(window).to have_key(:points) + expect(window[:points].size).to eq(4) + next unless i == win - # Testing summaries function. - argh[:io][:description] = "testing non thermal bridging" + expect(window).to have_key(:gross) + expect(window[:gross]).to be_within(TOL).of(5.89) # ! API 5.58 + end - ua = TBD.ua_summary(Time.now, argh) - expect(ua).to_not be_nil - expect(ua).to be_a(Hash) - expect(ua).to_not be_empty - expect(ua).to have_key(:model) + # Adding a clerestory window, slightly above "Office Front Wall Window 1", + # to test/validate overlapping cases. Starting with a safe case. + # + # FYI, original "Office Front Wall Window 1" (without F&D widths). + # 3.66, 0, 2.44 + # 3.66, 0, 0.91 + # 7.31, 0, 0.91 + # 7.31, 0, 2.44 + + cl_v = OpenStudio::Point3dVector.new + cl_v << OpenStudio::Point3d.new( 3.66, 0.00, 4.00) + cl_v << OpenStudio::Point3d.new( 3.66, 0.00, 2.47) + cl_v << OpenStudio::Point3d.new( 7.31, 0.00, 2.47) + cl_v << OpenStudio::Point3d.new( 7.31, 0.00, 4.00) + clerestory = OpenStudio::Model::SubSurface.new(cl_v, model_FD) + clerestory.setName("clerestory") + expect(clerestory.setSurface(front_FD)).to be true + expect(clerestory.setSubSurfaceType("FixedWindow")).to be true + # ... reminder: set subsurface type AFTER setting its parent surface! + + argh = { option: "poor (BETBG)" } + + json = TBD.process(model_FD, 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 # surfaces have already been derated + expect(TBD.logs.size).to eq(12) + expect(surfaces).to be_a(Hash) + expect(surfaces.size).to eq(23) + expect(surfaces).to have_key(wll) + expect(io).to be_a(Hash) + expect(io).to have_key(:edges) + expect(io[:edges].size).to eq(305) - en_ud_md = TBD.ua_md(ua, :en) - fr_ud_md = TBD.ua_md(ua, :fr) - path_en = File.join(__dir__, "files/ua/en_ua.md") - path_fr = File.join(__dir__, "files/ua/fr_ua.md") - File.open(path_en, "w") { |file| file.puts en_ud_md } - File.open(path_fr, "w") { |file| file.puts fr_ud_md } - end + expect(surfaces[wll]).to have_key(:windows) + wins = surfaces[wll][:windows] + expect(wins.size).to eq(3) + expect(wins).to have_key("clerestory") + expect(wins).to have_key(win) - it "can work off of a cloned model" do - translator = OpenStudio::OSVersion::VersionTranslator.new - TBD.clean! + expect(wins["clerestory"]).to have_key(:points) + expect(wins[win ]).to have_key(:points) - argh1 = { option: "poor (BETBG)" } - argh2 = { option: "poor (BETBG)" } - argh3 = { option: "poor (BETBG)" } + v1 = window_FD.vertices # original OSM vertices for window + f1 = TBD.offset(v1, width, 300) # offset vertices, forcing v300 version + expect(f1).to be_a(OpenStudio::Point3dVector) + expect(f1.size).to eq(4) - 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 - mdl = model.clone - fil = File.join(__dir__, "files/osms/out/alt_warehouse.osm") - mdl.save(fil, true) + f1.each { |f| expect(f).to be_a(OpenStudio::Point3d) } - # Despite one being the clone of the other, files will not be identical, - # namely due to unique handles. - expect(FileUtils).to_not be_identical(file, fil) + f1area = OpenStudio.getArea(f1) + expect(f1area).to_not be_empty + f1area = f1area.get - TBD.process(model, argh1) - expect(TBD.status).to be_zero - expect(TBD.logs).to be_empty + expect(f1area).to be_within(TOL).of(5.89 ) + expect(f1area).to be_within(TOL).of(wins[win][:area ]) + expect(f1area).to be_within(TOL).of(wins[win][:gross]) - expect(argh1).to have_key(:surfaces) - expect(argh1).to have_key(:io) + # For SDK versions prior to v321, the offset vertices are generated in the + # right order with respect to the original subsurface vertices. + expect((f1[0].x - v1[0].x).abs).to be_within(TOL).of(width) + expect((f1[1].x - v1[1].x).abs).to be_within(TOL).of(width) + expect((f1[2].x - v1[2].x).abs).to be_within(TOL).of(width) + expect((f1[3].x - v1[3].x).abs).to be_within(TOL).of(width) + expect((f1[0].y - v1[0].y).abs).to be_within(TOL).of(0 ) + expect((f1[1].y - v1[1].y).abs).to be_within(TOL).of(0 ) + expect((f1[2].y - v1[2].y).abs).to be_within(TOL).of(0 ) + expect((f1[3].y - v1[3].y).abs).to be_within(TOL).of(0 ) + expect((f1[0].z - v1[0].z).abs).to be_within(TOL).of(width) + expect((f1[1].z - v1[1].z).abs).to be_within(TOL).of(width) + expect((f1[2].z - v1[2].z).abs).to be_within(TOL).of(width) + expect((f1[3].z - v1[3].z).abs).to be_within(TOL).of(width) - expect(argh1[:surfaces]).to be_a(Hash) - expect(argh1[:surfaces].size).to eq(23) + v2 = clerestory.vertices + p2 = wins["clerestory"][:points] # same as original OSM vertices - expect(argh1[:io]).to be_a(Hash) - expect(argh1[:io]).to have_key(:edges) - expect(argh1[:io][:edges].size).to eq(300) + expect((p2[0].x - v2[0].x).abs).to be_within(TOL).of(0) + expect((p2[1].x - v2[1].x).abs).to be_within(TOL).of(0) + expect((p2[2].x - v2[2].x).abs).to be_within(TOL).of(0) + expect((p2[3].x - v2[3].x).abs).to be_within(TOL).of(0) + expect((p2[0].y - v2[0].y).abs).to be_within(TOL).of(0) + expect((p2[1].y - v2[1].y).abs).to be_within(TOL).of(0) + expect((p2[2].y - v2[2].y).abs).to be_within(TOL).of(0) + expect((p2[3].y - v2[3].y).abs).to be_within(TOL).of(0) + expect((p2[0].z - v2[0].z).abs).to be_within(TOL).of(0) + expect((p2[1].z - v2[1].z).abs).to be_within(TOL).of(0) + expect((p2[2].z - v2[2].z).abs).to be_within(TOL).of(0) + expect((p2[3].z - v2[3].z).abs).to be_within(TOL).of(0) - out = JSON.pretty_generate(argh1[:io]) - outP = File.join(__dir__, "../json/tbd_warehouse12.out.json") - File.open(outP, "w") { |outP| outP.puts out } + # In addition, the top of the "Office Front Wall Window 1" is aligned with + # the bottom of the clerestory, i.e. no conflicts between siblings. + expect((f1[0].z - p2[1].z).abs).to be_within(TOL).of(0) + expect((f1[3].z - p2[2].z).abs).to be_within(TOL).of(0) + expect(TBD.warn?).to be true + # Testing both 'fits?' & 'overlaps?' functions. TBD.clean! - fil = File.join(__dir__, "files/osms/out/alt_warehouse.osm") - pth = OpenStudio::Path.new(fil) - mdl = translator.loadModel(pth) - expect(mdl).to_not be_empty - mdl = mdl.get - - TBD.process(mdl, argh2) - expect(TBD.status).to be_zero - expect(TBD.logs).to be_empty - - expect(argh2).to have_key(:surfaces) - expect(argh2).to have_key(:io) + vec2 = OpenStudio::Point3dVector.new - expect(argh2[:surfaces]).to be_a(Hash) - expect(argh2[:surfaces].size).to eq(23) + p2.each { |p| vec2 << OpenStudio::Point3d.new(p.x, p.y, p.z) } - expect(argh2[:io]).to be_a(Hash) - expect(argh2[:io]).to have_key(:edges) - expect(argh2[:io][:edges].size).to eq(300) + expect(TBD.fits?(f1, vec2)).to be false + expect(TBD.overlaps?(f1, vec2)).to be false + expect(TBD.status).to be_zero - # The JSON output files are identical. - out2 = JSON.pretty_generate(argh2[:io]) - outP2 = File.join(__dir__, "../json/tbd_warehouse13.out.json") - File.open(outP2, "w") { |outP2| outP2.puts out2 } - expect(FileUtils).to be_identical(outP, outP2) + # Same exercise, yet provide clerestory with Frame & Divider. + fd2 = OpenStudio::Model::WindowPropertyFrameAndDivider.new(model_FD) + width2 = 0.06 + expect(fd2.setFrameWidth(width2)).to be true + expect(fd2.setFrameConductance(2.500)).to be true + expect(clerestory.allowWindowPropertyFrameAndDivider).to be true + expect(clerestory.setWindowPropertyFrameAndDivider(fd2)).to be true + width3 = clerestory.windowPropertyFrameAndDivider.get.frameWidth + expect(width3).to be_within(TOL).of(width2) - time = Time.now + argh = { option: "poor (BETBG)" } - # Original output UA' MD file. - argh1[:ua_ref ] = "code (Quebec)" - argh1[:io][:description] = "testing equality" - argh1[:version ] = OpenStudio.openStudioVersion - argh1[:seed ] = File.join(__dir__, "files/osms/in/warehouse.osm") + json = TBD.process(model_FD, 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.error?).to be true # conflict between F&D windows + expect(TBD.logs.size).to eq(13) + expect(surfaces).to be_a(Hash) + expect(surfaces.size).to eq(23) + expect(surfaces).to have_key(wll) + expect(io).to be_a(Hash) + expect(io).to have_key(:edges) + expect(io[:edges].size).to eq(304) - o_ua = TBD.ua_summary(time, argh1) - expect(o_ua).to_not be_nil - expect(o_ua).to_not be_empty - expect(o_ua).to be_a(Hash) - expect(o_ua).to have_key(:model) + expect(surfaces[wll]).to have_key(:windows) + wins = surfaces[wll][:windows] + expect(wins.size).to eq(3) + expect(wins).to have_key("clerestory") + expect(wins).to have_key(win) + expect(wins["clerestory"]).to have_key(:points) + expect(wins[win ]).to have_key(:points) - o_ud_md_en = TBD.ua_md(o_ua, :en) - path1 = File.join(__dir__, "files/ua/o_ua_en.md") - File.open(path1, "w") { |file| file.puts o_ud_md_en } + # As there are conflicts between both windows (due to conflicting Frame & + # Divider parameters), TBD will ignore Frame & Divider coordinates and fall + # back to original OpenStudio subsurface vertices. + v1 = window_FD.vertices # original OSM vertices for window + p1 = wins[win][:points] # Topolys vertices, as original - # Alternate output UA' MD file. - argh2[:ua_ref ] = "code (Quebec)" - argh2[:io][:description] = "testing equality" - argh2[:version ] = OpenStudio.openStudioVersion - argh2[:seed ] = File.join(__dir__, "files/osms/in/warehouse.osm") + expect((p1[0].x - v1[0].x).abs).to be_within(TOL).of(0) + expect((p1[1].x - v1[1].x).abs).to be_within(TOL).of(0) + expect((p1[2].x - v1[2].x).abs).to be_within(TOL).of(0) + expect((p1[3].x - v1[3].x).abs).to be_within(TOL).of(0) + expect((p1[0].y - v1[0].y).abs).to be_within(TOL).of(0) + expect((p1[1].y - v1[1].y).abs).to be_within(TOL).of(0) + expect((p1[2].y - v1[2].y).abs).to be_within(TOL).of(0) + expect((p1[3].y - v1[3].y).abs).to be_within(TOL).of(0) + expect((p1[0].z - v1[0].z).abs).to be_within(TOL).of(0) + expect((p1[1].z - v1[1].z).abs).to be_within(TOL).of(0) + expect((p1[2].z - v1[2].z).abs).to be_within(TOL).of(0) + expect((p1[3].z - v1[3].z).abs).to be_within(TOL).of(0) - alt_ua = TBD.ua_summary(time, argh2) - expect(alt_ua).to_not be_nil - expect(alt_ua).to_not be_empty - expect(alt_ua).to be_a(Hash) - expect(alt_ua).to have_key(:model) + v2 = clerestory.vertices + p2 = wins["clerestory"][:points] # same as original OSM vertices - alt_ud_md_en = TBD.ua_md(alt_ua, :en) - path2 = File.join(__dir__, "files/ua/alt_ua_en.md") - File.open(path2, "w") { |file| file.puts alt_ud_md_en } + expect((p2[0].x - v2[0].x).abs).to be_within(TOL).of(0) + expect((p2[1].x - v2[1].x).abs).to be_within(TOL).of(0) + expect((p2[2].x - v2[2].x).abs).to be_within(TOL).of(0) + expect((p2[3].x - v2[3].x).abs).to be_within(TOL).of(0) + expect((p2[0].y - v2[0].y).abs).to be_within(TOL).of(0) + expect((p2[1].y - v2[1].y).abs).to be_within(TOL).of(0) + expect((p2[2].y - v2[2].y).abs).to be_within(TOL).of(0) + expect((p2[3].y - v2[3].y).abs).to be_within(TOL).of(0) + expect((p2[0].z - v2[0].z).abs).to be_within(TOL).of(0) + expect((p2[1].z - v2[1].z).abs).to be_within(TOL).of(0) + expect((p2[2].z - v2[2].z).abs).to be_within(TOL).of(0) + expect((p2[3].z - v2[3].z).abs).to be_within(TOL).of(0) - # Both output UA' MD files should be identical. - expect(TBD.status).to be_zero - expect(TBD.logs).to be_empty - expect(FileUtils).to be_identical(path1, path2) + # In addition, the top of the "Office Front Wall Window 1" is no longer + # aligned with the bottom of the clerestory. + expect(((p1[0].z - p2[1].z).abs - width).abs).to be_within(TOL).of(0) + expect(((p1[3].z - p2[2].z).abs - width).abs).to be_within(TOL).of(0) - # Testing the Macumber suggestion (thumbs' up). TBD.clean! + vec1 = OpenStudio::Point3dVector.new + vec2 = OpenStudio::Point3dVector.new - 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 - - mdl2 = OpenStudio::Model::Model.new - mdl2.addObjects(model.toIdfFile.objects) - fil2 = File.join(__dir__, "files/osms/out/alt2_warehouse.osm") - mdl2.save(fil2, true) - - # Still get the differences in handles (not consequential at all if the TBD - # JSON output files are identical). - expect(FileUtils).to_not be_identical(file, fil2) + p1.each { |p| vec1 << OpenStudio::Point3d.new(p.x, p.y, p.z) } + p2.each { |p| vec2 << OpenStudio::Point3d.new(p.x, p.y, p.z) } - TBD.process(mdl2, argh3) + expect(TBD.fits?(vec1, vec2)).to be false + expect(TBD.overlaps?(vec1, vec2)).to be false expect(TBD.status).to be_zero - expect(TBD.logs).to be_empty - - expect(argh3).to have_key(:surfaces) - expect(argh3).to have_key(:io) - - expect(argh3[:surfaces]).to be_a(Hash) - expect(argh3[:surfaces].size).to eq(23) - - expect(argh3[:io]).to be_a(Hash) - expect(argh3[:io]).to have_key(:edges) - expect(argh3[:io][:edges].size).to eq(300) - out3 = JSON.pretty_generate(argh3[:io]) - outP3 = File.join(__dir__, "../json/tbd_warehouse14.out.json") - File.open(outP3, "w") { |outP3| outP3.puts out3 } - # Nice. Both TBD JSON output files are identical! - # "/../json/tbd_warehouse12.out.json" vs "/../json/tbd_warehouse14.out.json" - expect(FileUtils).to be_identical(outP, outP3) - end + # --- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- --- # + # Testing more complex cases, e.g. triangular windows, irregular 4-side + # windows, rough opening edges overlapping parent surface edges. There's + # overlap between this set of tests and a similar set in OSut. + model = OpenStudio::Model::Model.new + space = OpenStudio::Model::Space.new(model) + space.setName("Space") - it "can generate and access KIVA inputs (seb)" do - translator = OpenStudio::OSVersion::VersionTranslator.new - TBD.clean! + # Windows are SimpleGlazing constructions. + fen = OpenStudio::Model::Construction.new(model) + glazing = OpenStudio::Model::SimpleGlazing.new(model) + layers = OpenStudio::Model::MaterialVector.new + fen.setName("FD fen") + glazing.setName("FD glazing") + expect(glazing.setUFactor(2.0)).to be true + layers << glazing + expect(fen.setLayers(layers)).to be true - # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # - file = File.join(__dir__, "files/osms/out/seb2.osm") - path = OpenStudio::Path.new(file) - model = translator.loadModel(path) - expect(model).to_not be_empty - model = model.get + # Frame & Divider object. + w000 = 0.000 + w200 = 0.200 # 0mm to 200mm (wide!) around glazing + fd = OpenStudio::Model::WindowPropertyFrameAndDivider.new(model) + fd.setName("FD") + expect(fd.setFrameConductance(0.500)).to be true + expect(fd.isFrameWidthDefaulted).to be true + expect(fd.frameWidth).to be_within(TOL).of(w000) - # Fetch all 5 outdoor-facing walls of the Open Area space. - oa13ID = "Openarea 1 Wall 3" - oa14ID = "Openarea 1 Wall 4" - oa15ID = "Openarea 1 Wall 5" - oa16ID = "Openarea 1 Wall 6" - oa17ID = "Openarea 1 Wall 7" - oaIDs = [oa13ID, oa14ID, oa15ID, oa16ID, oa17ID] + # A square base wall surface: + v0 = OpenStudio::Point3dVector.new + v0 << OpenStudio::Point3d.new( 0.00, 0.00, 10.00) + v0 << OpenStudio::Point3d.new( 0.00, 0.00, 0.00) + v0 << OpenStudio::Point3d.new(10.00, 0.00, 0.00) + v0 << OpenStudio::Point3d.new(10.00, 0.00, 10.00) - oa13 = model.getSurfaceByName(oa13ID) - oa14 = model.getSurfaceByName(oa14ID) - oa15 = model.getSurfaceByName(oa15ID) - oa16 = model.getSurfaceByName(oa16ID) - oa17 = model.getSurfaceByName(oa17ID) - expect(oa13).to_not be_empty - expect(oa14).to_not be_empty - expect(oa15).to_not be_empty - expect(oa16).to_not be_empty - expect(oa17).to_not be_empty - oa13 = oa13.get - oa14 = oa14.get - oa15 = oa15.get - oa16 = oa16.get - oa17 = oa17.get + # A first triangular window: + v1 = OpenStudio::Point3dVector.new + v1 << OpenStudio::Point3d.new( 2.00, 0.00, 8.00) + v1 << OpenStudio::Point3d.new( 1.00, 0.00, 6.00) + v1 << OpenStudio::Point3d.new( 4.00, 0.00, 9.00) - woa13 = TBD.alignedWidth(oa13) - woa14 = TBD.alignedWidth(oa14) - woa15 = TBD.alignedWidth(oa15) - woa16 = TBD.alignedWidth(oa16) - woa17 = TBD.alignedWidth(oa17) - expect(woa13.round(2)).to eq(2.29) - expect(woa14.round(2)).to eq(2.14) - expect(woa15.round(2)).to eq(3.89) - expect(woa16.round(2)).to eq(2.45) - expect(woa17.round(2)).to eq(1.82) + # A larger, irregular window: + v2 = OpenStudio::Point3dVector.new + v2 << OpenStudio::Point3d.new( 7.00, 0.00, 4.00) + v2 << OpenStudio::Point3d.new( 4.00, 0.00, 1.00) + v2 << OpenStudio::Point3d.new( 8.00, 0.00, 2.00) + v2 << OpenStudio::Point3d.new( 9.00, 0.00, 3.00) - # Assert 'exposed perimeter' of the Open Area space. - exp = woa13 + woa14 + woa15 + woa16 + woa17 - expect(exp.round(2)).to eq(12.59) + # A final triangular window, near the wall's upper right corner: + v3 = OpenStudio::Point3dVector.new + v3 << OpenStudio::Point3d.new( 9.00, 0.00, 9.80) + v3 << OpenStudio::Point3d.new( 9.80, 0.00, 9.00) + v3 << OpenStudio::Point3d.new( 9.80, 0.00, 9.80) - # For continuous insulation and/or finishings, OpenStudio/EnergyPlus/Kiva - # offer 2x solutions : - # - # 1. Add standard - not massless - materials as new construction layers - # 2. Add Kiva custom blocks - # - # ... sticking with Option #1. A few examples: + w0 = OpenStudio::Model::Surface.new(v0, model) + w1 = OpenStudio::Model::SubSurface.new(v1, model) + w2 = OpenStudio::Model::SubSurface.new(v2, model) + w3 = OpenStudio::Model::SubSurface.new(v3, model) + w0.setName("w0") + w1.setName("w1") + w2.setName("w2") + w3.setName("w3") + expect(w0.setSpace(space)).to be true + sub_gross = 0 - # Generic 1-1/2" XPS insulation. - xps_38mm = OpenStudio::Model::StandardOpaqueMaterial.new(model) - xps_38mm.setName("XPS_38mm") - xps_38mm.setRoughness("Rough") - xps_38mm.setThickness(0.0381) - xps_38mm.setConductivity(0.029) - xps_38mm.setDensity(28) - xps_38mm.setSpecificHeat(1450) - xps_38mm.setThermalAbsorptance(0.9) - xps_38mm.setSolarAbsorptance(0.7) + [w1, w2, w3].each do |w| + expect(w.setSubSurfaceType("FixedWindow")).to be true + expect(w.setSurface(w0)).to be true + expect(w.setConstruction(fen)).to be true + expect(w.uFactor).to_not be_empty + expect(w.uFactor.get).to be_within(0.1).of(2.0) + expect(w.allowWindowPropertyFrameAndDivider).to be true + expect(w.setWindowPropertyFrameAndDivider(fd)).to be true + width = w.windowPropertyFrameAndDivider.get.frameWidth + expect(width).to be_within(TOL).of(w000) - # 1. Current code-compliant slab-on-grade (perimeter) solution. - kiva_slab_2020s = OpenStudio::Model::FoundationKiva.new(model) - kiva_slab_2020s.setName("Kiva slab 2020s") - kiva_slab_2020s.setInteriorHorizontalInsulationMaterial(xps_38mm) - kiva_slab_2020s.setInteriorHorizontalInsulationWidth(1.2) - kiva_slab_2020s.setInteriorVerticalInsulationMaterial(xps_38mm) - kiva_slab_2020s.setInteriorVerticalInsulationDepth(0.138) + sub_gross += w.grossArea + end - # 2. Beyond-code slab-on-grade (continuous) insulation setup. Add 1-1/2" - # XPS insulation layer (under slab) to surface construction. - kiva_slab_HP = OpenStudio::Model::FoundationKiva.new(model) - kiva_slab_HP.setName("Kiva slab HP") + expect(w1.grossArea).to be_within(TOL).of(1.50) + expect(w2.grossArea).to be_within(TOL).of(6.00) + expect(w3.grossArea).to be_within(TOL).of(0.32) + expect(w0.grossArea).to be_within(TOL).of(100.00) + expect(w1.netArea).to be_within(TOL).of(w1.grossArea) + expect(w2.netArea).to be_within(TOL).of(w2.grossArea) + expect(w3.netArea).to be_within(TOL).of(w3.grossArea) + expect(w0.netArea).to be_within(TOL).of(w0.grossArea - sub_gross) - # 3. Do the same for (full height) basements - no insulation under slab for - # vintages 1980s & 2020s. Add (full-height) layered insulation and/or - # finishing to basement wall construction. - kiva_basement = OpenStudio::Model::FoundationKiva.new(model) - kiva_basement.setName("Kiva basement") + # Applying 2 sets of alterations: + # - without, then with Frame & Dividers (F&D) + # - 3 successive 20deg rotations around: + angle = Math::PI / 9 + origin = OpenStudio::Point3d.new(0, 0, 0) + east = OpenStudio::Point3d.new(1, 0, 0) - origin + up = OpenStudio::Point3d.new(0, 0, 1) - origin + north = OpenStudio::Point3d.new(0, 1, 0) - origin - # 4. Beyond-code basement slab (perimeter) insulation setup. Add - # (full-height)layered insulation and/or finishing to basement wall - # construction. - kiva_basement_HP = OpenStudio::Model::FoundationKiva.new(model) - kiva_basement_HP.setName("Kiva basement HP") - kiva_basement_HP.setInteriorHorizontalInsulationMaterial(xps_38mm) - kiva_basement_HP.setInteriorHorizontalInsulationWidth(1.2) - kiva_basement_HP.setInteriorVerticalInsulationMaterial(xps_38mm) - kiva_basement_HP.setInteriorVerticalInsulationDepth(0.138) + 4.times.each do |i| # successive rotations + unless i.zero? + r = OpenStudio.createRotation(origin, east, angle) if i == 1 + r = OpenStudio.createRotation(origin, up, angle) if i == 2 + r = OpenStudio.createRotation(origin, north, angle) if i == 3 + expect(w0.setVertices(r.inverse * w0.vertices)).to be true + expect(w1.setVertices(r.inverse * w1.vertices)).to be true + expect(w2.setVertices(r.inverse * w2.vertices)).to be true + expect(w3.setVertices(r.inverse * w3.vertices)).to be true + end - # Set "Foundation" as boundary condition of 1x slab-on-grade, and link it - # to 1x Kiva Foundation object. - oa1f = model.getSurfaceByName("Open area 1 Floor") - expect(oa1f).to_not be_empty - oa1f = oa1f.get + 2.times.each do |j| # F&D + if j.zero? + wx = w000 + fd.resetFrameWidth unless i.zero? + else + wx = w200 + expect(fd.setFrameWidth(wx)).to be true - expect(oa1f.setOutsideBoundaryCondition("Foundation")).to be true - oa1f.setAdjacentFoundation(kiva_slab_2020s) - construction = oa1f.construction - expect(construction).to_not be_empty - construction = construction.get - expect(oa1f.setConstruction(construction)).to be true + [w1, w2, w3].each do |w| + width = w.windowPropertyFrameAndDivider.get.frameWidth + expect(width).to be_within(TOL).of(wx) + end + end - arg = "TotalExposedPerimeter" - per = oa1f.createSurfacePropertyExposedFoundationPerimeter(arg, exp) - expect(per).to_not be_empty + # TBD's 'properties' relies on OSut's 'offset' solution when dealing + # with subsurfaces with F&D. It offers 3x options: + # 1. native, 3D vector-based calculations (only option for OS < v321) + # 2. SDK's reliance on Boost's 'buffer' (default for v321 < OS < v340) + # 3. SDK's 'rough opening' vertices (default for SDK v340+) + # + # Options #2 & #3 both rely on Boost's 'buffer' method. But SDK v340+ + # doesn't correct Boost-generated vertices (back to counterclockwise). + # Option #2 ensures counterclockwise sequences, although the first + # vertex in the array is no longer in sync with the original OpenStudio + # vertices. Not consequential for fitting and overlapping detection, or + # net/gross/rough areas tallies. Otherwise, both options generate the + # same vertices. + # + # For triangular subsurfaces, Options #2 & #3 may generate additional + # vertices near acute angles, e.g. 6 (3 of which would be ~colinear). + # Calculated areas, as well as fitting & overlapping detection, still + # work. Yet inaccuracies do creep in with respect to Option #1. To + # maintain consistency in TBD calculations when switching SDK versions, + # TBD's use of OSut's offset method is as follows (see 'properties' in + # geo.rb): + # + # offset(s.vertices, width, 300) + # + # There may be slight differences in reported SDK results vs TBD UA + # reports (e.g. WWR, net areas) with acute triangular windows ... which + # is fine. + surface = TBD.properties(w0, argh) + expect(surface).to_not be_nil + expect(surface).to be_a(Hash) + expect(surface).to have_key(:gross) + expect(surface).to have_key(:net) + expect(surface).to have_key(:windows) + expect(surface[:gross]).to be_a(Numeric) + expect(surface[:gross]).to be_within(0.1).of(100) + expect(surface[:windows]).to be_a(Hash) + expect(surface[:windows]).to have_key("w1") + expect(surface[:windows]).to have_key("w2") + expect(surface[:windows]).to have_key("w3") + expect(surface[:windows]["w1"]).to be_a(Hash) + expect(surface[:windows]["w2"]).to be_a(Hash) + expect(surface[:windows]["w3"]).to be_a(Hash) + expect(surface[:windows]["w1"]).to have_key(:gross) + expect(surface[:windows]["w2"]).to have_key(:gross) + expect(surface[:windows]["w3"]).to have_key(:gross) + expect(surface[:windows]["w1"]).to have_key(:points) + expect(surface[:windows]["w2"]).to have_key(:points) + expect(surface[:windows]["w3"]).to have_key(:points) + expect(surface[:windows]["w1"][:points].size).to eq(3) + expect(surface[:windows]["w2"][:points].size).to eq(4) + expect(surface[:windows]["w3"][:points].size).to eq(3) - file = File.join(__dir__, "files/osms/out/seb_KIVA.osm") - model.save(file, true) + if j.zero? + expect(surface[:windows]["w1"][:gross]).to be_within(TOL).of(1.50) + expect(surface[:windows]["w2"][:gross]).to be_within(TOL).of(6.00) + expect(surface[:windows]["w3"][:gross]).to be_within(TOL).of(0.32) + else + expect(surface[:windows]["w1"][:gross]).to be_within(TOL).of(3.75) + expect(surface[:windows]["w2"][:gross]).to be_within(TOL).of(8.64) + expect(surface[:windows]["w3"][:gross]).to be_within(TOL).of(1.10) + end + end + end + + # Neither warning nor error == no conflicts between windows (with new + # new vertices offset by 200mm) and with the base wall. + expect(TBD.status).to be_zero + end + it "can flag errors and integrate TBD logs in JSON output" do + translator = OpenStudio::OSVersion::VersionTranslator.new + TBD.clean! - # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # - # Re-open for testing. + 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 - oa1f = model.getSurfaceByName("Open area 1 Floor") - expect(oa1f).to_not be_empty - oa1f = oa1f.get + office = model.getSpaceByName("Zone1 Office") + expect(office).to_not be_empty - expect(oa1f.outsideBoundaryCondition.downcase).to eq("foundation") - foundation = oa1f.adjacentFoundation - expect(foundation).to_not be_empty - foundation = foundation.get + front_office_wall = model.getSurfaceByName("Office Front Wall") + expect(front_office_wall).to_not be_empty + front_office_wall = front_office_wall.get + expect(front_office_wall.nameString).to eq("Office Front Wall") + expect(front_office_wall.surfaceType).to eq("Wall") - oa15 = model.getSurfaceByName(oa15ID) - expect(oa15).to_not be_empty - oa15 = oa15.get + left_office_wall = model.getSurfaceByName("Office Left Wall") + expect(left_office_wall).to_not be_empty + left_office_wall = left_office_wall.get + expect(left_office_wall.nameString).to eq("Office Left Wall") + expect(left_office_wall.surfaceType).to eq("Wall") - construction = oa15.construction.get - expect(oa15.setOutsideBoundaryCondition("Foundation")).to be true - expect(oa15.setAdjacentFoundation(foundation)).to be true - expect(oa15.setConstruction(construction)).to be true + right_fine_wall = model.getSurfaceByName("Fine Storage Right Wall") + expect(right_fine_wall).to_not be_empty + right_fine_wall = right_fine_wall.get + expect(right_fine_wall.nameString).to eq("Fine Storage Right Wall") + expect(right_fine_wall.surfaceType).to eq("Wall") - kfs = model.getFoundationKivas - expect(kfs).to_not be_empty - expect(kfs.size).to eq(4) - expect(model.foundationKivaSettings).to be_empty + # Adding a small, 5-sided window to the "Office Front Wall" (above door). + os_v = OpenStudio::Point3dVector.new + os_v << OpenStudio::Point3d.new( 12.96, 0.00, 4.00) + os_v << OpenStudio::Point3d.new( 12.04, 0.00, 3.50) + os_v << OpenStudio::Point3d.new( 12.04, 0.00, 2.50) + os_v << OpenStudio::Point3d.new( 13.87, 0.00, 2.50) + os_v << OpenStudio::Point3d.new( 13.87, 0.00, 3.50) + clerestory = OpenStudio::Model::SubSurface.new(os_v, model) + clerestory.setName("clerestory") + expect(clerestory.setSurface(front_office_wall)).to be true + expect(clerestory.setSubSurfaceType("FixedWindow")).to be true + # ... reminder: set subsurface type AFTER setting its parent surface. - argh = {} - argh[:option ] = "poor (BETBG)" - argh[:gen_kiva] = true + # A new, highly-conductive material. + material = OpenStudio::Model::MasslessOpaqueMaterial.new(model) + material.setName("poor material") + expect(material.nameString).to eq("poor material") + expect(material.setThermalResistance(RMIN)).to be true + mat = OpenStudio::Model::MaterialVector.new + mat << material + + # A 'standard' variant (RMIN) + material2 = OpenStudio::Model::StandardOpaqueMaterial.new(model) + material2.setName("poor material2") + expect(material2.nameString).to eq("poor material2") + expect(material2.setThermalConductivity(KMAX)).to be true + expect(material2.setThickness(DMIN)).to be true + mat2 = OpenStudio::Model::MaterialVector.new + mat2 << material2 + + # Another 'massless' material, whose name already includes " tbd". + material3 = OpenStudio::Model::MasslessOpaqueMaterial.new(model) + material3.setName("poor material m tbd") + expect(material3.nameString).to eq("poor material m tbd") + expect(material3.setThermalResistance(1.0)).to be true + expect(material3.thermalResistance).to be_within(0.1).of(1.0) + mat3 = OpenStudio::Model::MaterialVector.new + mat3 << material3 + + # Assign highly-conductive material to a new construction. + construction = OpenStudio::Model::Construction.new(model) + construction.setName("poor construction") + expect(construction.nameString).to eq("poor construction") + expect(construction.layers).to be_empty + expect(construction.setLayers(mat2)).to be true # or switch with 'mat' + expect(construction.layers.size).to eq(1) + + # Assign " tbd" massless material to a new construction. + construction2 = OpenStudio::Model::Construction.new(model) + construction2.setName("poor construction tbd") + expect(construction2.nameString).to eq("poor construction tbd") + expect(construction2.layers).to be_empty + expect(construction2.setLayers(mat3)).to be true + expect(construction2.layers.size).to eq(1) + + # Assign construction to the "Office Left Wall". + expect(left_office_wall.setConstruction(construction)).to be true + + # Assign construction2 to the "Fine Storage Right Wall". + expect(right_fine_wall.setConstruction(construction2)).to be true + + subs = front_office_wall.subSurfaces + expect(subs).to_not be_empty + expect(subs.size).to eq(4) + + argh = {} + argh[:option ] = "poor (BETBG)" + argh[:io_path ] = File.join(__dir__, "../json/tbd_warehouse9.json") + argh[:schema_path] = File.join(__dir__, "../tbd.schema.json") + # { + # "schema": "https://github.com/rd2/tbd/blob/master/tbd.schema.json", + # "description": "testing error detection", + # "psis": [ + # { + # "id": "detailed 2", + # "fen": 0.600 + # }, + # { + # "id": "regular (BETBG)", <<<< ERROR #1 - can't reset built-in sets + # "fen": 0.700 + # } + # ], + # "khis": [ + # { + # "id": "cantilevered beam", + # "point": 0.6 + # } + # ], + # "surfaces": [ + # { + # "id": "Office Front Wall", + # "khis": [ + # { + # "id": "beam", <<<< ERROR #2 - 'beam' not previously defined + # "count": 3 + # } + # ] + # }, + # { + # "id": "Office Left Wall", + # "khis": [ + # { + # "id": "cantilevered beam", + # "count": 300 <<<< WARNING #1 - heat loss too great (for m2) + # } + # ] + # } + # ], + # "edges": [ + # { + # "psi": "detailed", <<<< ERROR #3 - 'detailed' not previously defined + # "type": "fen", + # "surfaces": [ + # "Office Front Wall", + # "Office Front Wall Window 1" + # ] + # } + # ] + # } json = TBD.process(model, argh) expect(json).to be_a(Hash) @@ -9011,197 +8871,231 @@ io = json[:io ] surfaces = json[:surfaces] expect(TBD.error?).to be true - expect(TBD.logs.size).to eq(1) - expect(TBD.logs.first[:message]).to include("Exiting - KIVA objects in ") + expect(TBD.logs.size).to eq(6) + expect(io).to be_a(Hash) expect(surfaces).to be_a(Hash) - expect(surfaces.size).to eq(56) + expect(surfaces.size).to eq(23) expect(io).to be_a(Hash) expect(io).to have_key(:edges) + expect(io[:edges].size).to eq(300) - # 105x edges (-1x than the usual 106x for the seb2.osm). The edge linking - # "Open area 1 Floor" to "Openarea 1 Wall 5" used to be of type :grade. As - # both slab and wall are now ground-facing, TBD ignores the edge altogether. - expect(io[:edges].size).to eq(105) - expect(model.foundationKivaSettings).to be_empty - expect(model.getSurfacePropertyExposedFoundationPerimeters.size).to eq(1) - expect(model.getFoundationKivas.size).to eq(4) - - # TBD derates (above-grade) surfaces as usual. TBD is certainly 'aware' of - # the "Foundation"-facing slab and wall (and their shared edge), yet exits - # the KIVA generation step. As the warning message suggests, TBD safely - # exits when the OpenStudio model already holds KIVA objects. - surfaces.values.each { |surface| expect(surface).to_not have_key(:kiva) } - - # As with the previously altered "files/osms/out/seb_KIVA.osm", OpenStudio - # can forward-translate and run an EnergyPlus simulation without warnings or - # errors. As "Openarea 1 Wall 5" is now a "Foundation"-facing wall, the - # exposed foundation perimeter length (set previously) is now invalid. Yet - # there are no internal checks in OpenStudio and/or EnergyPlus to ensure - # perimeter length consistency, WHEN exposed + foundation perimeter - # lengths < total slab perimeter lengths. Simulation runs without a glitch; - # simulation results would be 'off'. - file = File.join(__dir__, "files/osms/out/seb_KIVA2.osm") - model.save(file, true) - - - # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # - # Try again, yet by first purging existing KIVA objects in the model. - TBD.clean! - file = File.join(__dir__, "files/osms/out/seb_KIVA.osm") - path = OpenStudio::Path.new(file) - model = translator.loadModel(path) - expect(model).to_not be_empty - model = model.get + expect(surfaces).to have_key("Office Front Wall") + expect(surfaces).to have_key("Office Left Wall") + expect(surfaces).to have_key("Fine Storage Right Wall") - kfs = model.getFoundationKivas - expect(kfs).to_not be_empty - expect(kfs.size).to eq(4) - expect(model.foundationKivaSettings).to be_empty + expect(surfaces["Office Front Wall"]).to have_key(:edges) + expect(surfaces["Office Left Wall"]).to have_key(:edges) + expect(surfaces["Fine Storage Right Wall"]).to have_key(:edges) - oa1f = model.getSurfaceByName("Open area 1 Floor") - expect(oa1f).to_not be_empty - oa1f = oa1f.get + # TBD.logs.each { |log| puts log[:message] } + # Skipping 'clerestory': vertex # 3 or 4 (TBD::properties) + # 'regular (BETBG)': existing PSI set (TBD::append) + # JSON/KHI surface 'Office Front Wall' 'beam' (TBD::inputs) + # Missing edge PSI detailed (TBD::inputs) + # Won't derate 'poor construction tbd 1': tagged as derated (TBD::derate) + # Won't assign 197.714 W/K to 'Office Left Wall': too conductive (TBD::derate) - expect(oa1f.outsideBoundaryCondition.downcase).to eq("foundation") - foundation = oa1f.adjacentFoundation - expect(foundation).to_not be_empty + # Despite input file (non-fatal) errors, TBD successfully processes thermal + # bridges and derates OSM construction materials by falling back on defaults + # in the case of errors. - srfIDs = ["Open area 1 Floor"] + # For the 5-sided window, TBD will simply ignore all edges/bridges linked to + # the 'clerestory' subsurface. + io[:edges].each do |edge| + expect(edge).to have_key(:surfaces) - # Incrementally change Open Area outdoor-facing walls to foundation-facing, - # and ensure KIVA reset works. Exposed perimeter should remain the same. - oaIDs.each_with_index do |oaID, i| - i3 = i + 3 + edge[:surfaces].each { |s| expect(s).to_not eq("clerestory") } + end - oa1f = model.getSurfaceByName("Open area 1 Floor") - expect(oa1f).to_not be_empty - oa1f = oa1f.get + expect(surfaces["Office Front Wall"][:edges].size).to eq(17) + sills = 0 - expect(oa1f.outsideBoundaryCondition.downcase).to eq("foundation") - foundation = oa1f.adjacentFoundation - expect(foundation).to_not be_empty + surfaces["Office Front Wall"][:edges].values.each do |e| + expect(e).to have_key(:type) + sills += 1 if e[:type] == :sill + end - oaWALL = model.getSurfaceByName(oaID) - expect(oaWALL).to_not be_empty - oaWALL = oaWALL.get + expect(sills).to eq(2) # not 3 - construction = oaWALL.construction.get - expect(oaWALL.outsideBoundaryCondition.downcase).to eq("outdoors") - expect(oaWALL.setOutsideBoundaryCondition("Foundation")).to be true - expect(oaWALL.setConstruction(construction)).to be true + # Fallback to ERROR # 1: not really a fallback, more a demonstration that + # "regular (BETBG)" isn't referred to by any edge-linked derated surfaces. + # ... & fallback to ERROR # 3: no edge relying on 'detailed' PSI set. + io[:edges].each { |edge| expect(edge[:psi]).to eq("poor (BETBG)") } - srfIDs << oaID + # Fallback to ERROR # 2: no KHI for "Office Front Wall". + expect(io).to have_key(:khis) + expect(io[:khis].size).to eq(1) + expect(surfaces["Office Front Wall"]).to_not have_key(:khis) - argh = {} - argh[:option ] = "(non thermal bridging)" - argh[:gen_kiva ] = true - argh[:reset_kiva] = true + # ... concerning the "Office Left Wall" (underatable material). + left_office_wall = model.getSurfaceByName("Office Left Wall") + expect(left_office_wall).to_not be_empty + left_office_wall = left_office_wall.get - 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.info?).to be true - expect(TBD.logs.size).to eq(i + 1) + c = left_office_wall.construction.get.to_LayeredConstruction.get + expect(c.numLayers).to eq(1) + layer = c.getLayer(0).to_StandardOpaqueMaterial + expect(layer).to_not be_empty + layer = layer.get + expect(layer.name.get).to eq("Office Left Wall m tbd") + expect(layer.thermalConductivity).to be_within(0.1).of(KMAX) + expect(layer.thickness).to be_within(0.001).of(DMIN) - TBD.logs.each do |lg| - expect(lg[:message]).to include("Purged KIVA objects from ") - end + # Regardless of the targetted material type ('standard' vs 'massless'), TBD + # will ensure a minimal RSi value (see OSut RMIN), i.e. no derating despite + # the surface having thermal bridges. + expect(surfaces["Office Left Wall"]).to have_key(:heatloss) + expect(surfaces["Office Left Wall"]).to have_key(:r_heatloss) - expect(surfaces).to be_a(Hash) - expect(surfaces.size).to eq(56) - expect(io).to be_a(Hash) - expect(io).to have_key(:edges) - expect(io[:edges].size).to eq(105 - 2 * i) - expect(model.foundationKivaSettings).to_not be_empty - expect(model.getSurfacePropertyExposedFoundationPerimeters.size).to eq(1) - expect(model.getFoundationKivas.size).to eq(1) # !4 ... previously purged + expect(surfaces["Office Left Wall"][:heatloss ]).to be_within(0.1).of(197.7) + expect(surfaces["Office Left Wall"][:r_heatloss]).to be_within(0.1).of(197.7) - perimeter = model.getSurfacePropertyExposedFoundationPerimeters.first - expect(perimeter.totalExposedPerimeter).to_not be_empty - expect(perimeter.totalExposedPerimeter.get.round(2)).to eq(exp.round(2)) + expect(surfaces["Fine Storage Right Wall"]).to have_key(:heatloss) + expect(surfaces["Fine Storage Right Wall"]).to_not have_key(:r_heatloss) - # By default, KIVA foundation objects have a 200mm 'wall height above - # grade' value, i.e. a top, 8-in section exposed to outdoor air. This - # seems to generate the following EnergyPlus warning: - # - # ** Warning ** BuildingSurface:Detailed="OPENAREA 1 WALL 5", Sun Exposure="SUNEXPOSED". - # ** ~~~ ** ..This surface is not exposed to External Environment. Sun exposure has no effect. - # - # Initial attempts to get rid of the warning include resetting both wind - # and sun exposure AFTER setting boundary conditions to "Foundation", e.g. - # - # expect(wall.setOutsideBoundaryCondition("Foundation")).to be true - # expect(wall.setWindExposure("NoWind")).to be true - # expect(wall.setSunExposure("NoSun")).to be true - # - # Alas, both "exposures" end up being reset in the saved OSM. One solution - # is to first set the 'wall height above grade' value to 0. Works. - kf = model.getFoundationKivas.first - expect(kf.isWallHeightAboveGradeDefaulted).to be true - expect(kf.wallHeightAboveGrade.round(1)).to eq(0.2) - expect(kf.setWallHeightAboveGrade(0)).to be true - expect(kf.isWallHeightAboveGradeDefaulted).to be false - expect(kf.wallHeightAboveGrade.round).to eq(0) + # Concerning the new material (with a name already including " tbd"): + # TBD ignores all such materials (a safeguard against iterative TBD + # runs). Contrary to the previous critical cases of highly conductive + # materials, TBD doesn't even try to set the :r_heatloss hash value - tough! + right_fine_wall = model.getSurfaceByName("Fine Storage Right Wall") + expect(right_fine_wall).to_not be_empty + right_fine_wall = right_fine_wall.get - ewalls = TBD.facets(model.getSpaces, "foundation", "wall") - expect(ewalls.size).to eq(i + 1) + c = right_fine_wall.construction.get.to_LayeredConstruction.get + layer = c.getLayer(0).to_MasslessOpaqueMaterial + expect(layer).to_not be_empty + layer = layer.get + expect(layer.name.get).to eq("poor material m tbd") + expect(layer.thermalResistance).to be_within(0.1).of(1.0) - ewalls.each do |wall| - expect(wall.setWindExposure("NoWind")).to be true - expect(wall.setSunExposure("NoSun")).to be true - end + # Mimics (somewhat) the TBD 'measure.rb' method 'exitTBD()' + # ... should generate a 'logs' entry at the of the JSON output file. + status = TBD.msg(TBD.status) + status = TBD.msg(INF) if TBD.status.zero? - found_floor = false - found_walls = false + tbd_log = { date: Time.now, status: status } - surfaces.each do |id, surface| - next unless surface.key?(:kiva) + results = [] - expect(srfIDs).to include(id) + if surfaces + surfaces.each do |id, surface| + next if TBD.fatal? + next unless surface.key?(:ratio) - if id == "Open area 1 Floor" - expect(surface[:kiva]).to eq(:basement) - expect(surface).to have_key(:exposed) - expect(surface[:exposed]).to be_within(TOL).of(exp) - found_floor = true - else - expect(surface[:kiva]).to eq("Open area 1 Floor") - found_walls = true - end + ratio = format "%3.1f", surface[:ratio] + name = id.rjust(15, " ") + output = "#{name} RSi derated by #{ratio}%" + results << output end + end - expect(found_floor).to be true - expect(found_walls).to be true + tbd_log[:results] = results unless results.empty? + tbd_msgs = [] + + TBD.logs.each do |l| + tbd_msgs << { level: TBD.tag(l[:level]), message: l[:message] } end - file = File.join(__dir__, "files/osms/out/seb_KIVA3.osm") - model.save(file, true) + tbd_log[:messages] = tbd_msgs unless tbd_msgs.empty? + io[:log] = tbd_log - # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # - # Test initial model again. + # Deterministic sorting + io[:schema ] = io.delete(:schema ) if io.key?(:schema) + io[:description] = io.delete(:description) if io.key?(:description) + io[:log ] = io.delete(:log ) if io.key?(:log) + io[:psis ] = io.delete(:psis ) if io.key?(:psis) + io[:khis ] = io.delete(:khis ) if io.key?(:khis) + io[:building ] = io.delete(:building ) if io.key?(:building) + io[:stories ] = io.delete(:stories ) if io.key?(:stories) + io[:spacetypes ] = io.delete(:spacetypes ) if io.key?(:spacetypes) + io[:spaces ] = io.delete(:spaces ) if io.key?(:spaces) + io[:surfaces ] = io.delete(:surfaces ) if io.key?(:surfaces) + io[:edges ] = io.delete(:edges ) if io.key?(:edges) + + out = JSON.pretty_generate(io) + outP = File.join(__dir__, "../json/tbd_warehouse9.out.json") + File.open(outP, "w") { |outP| outP.puts out } + # ... should contain 'log' entries at the start of the JSON output file. + end + + it "can process an OSM converted from an IDF (with rotation)" do + translator = OpenStudio::OSVersion::VersionTranslator.new TBD.clean! - file = File.join(__dir__, "files/osms/out/seb2.osm") + + file = File.join(__dir__, "files/osms/in/5Zone_2.osm") path = OpenStudio::Path.new(file) model = translator.loadModel(path) expect(model).to_not be_empty model = model.get + plnum = model.getSpaceByName("PLENUM-1") + expect(plnum).to_not be_empty + plnum = plnum.get + + model.getSpaces.each do |space| + stpts = TBD.setpoints(space) + expect(stpts).to have_key(:heating) + expect(stpts).to have_key(:cooling) + expect(TBD.plenum?(space)).to be false + + if space == plnum + expect(stpts[:heating]).to be_nil + expect(stpts[:cooling]).to be_nil + else + expect(stpts[:heating]).to be_within(0.1).of(22.2) + expect(stpts[:cooling]).to be_within(0.1).of(23.9) + end + end + + # PLENUM floors. + flr_ids = ["C1-1P", "C2-1P", "C3-1P", "C4-1P", "C5-1P"] + + floors = model.getSurfaces.select { |s| flr_ids.include?(s.nameString) } + + floors.each do |fl| + expect(flr_ids).to include(fl.nameString) + space = fl.space + expect(space).to_not be_empty + expect(space.get).to eq(plnum) + + c = fl.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 eq("CLNG-1") + expect(c.layers.size).to eq(1) + expect(c.layers[0].nameString).to eq("MAT-CLNG-1") # RSi 0.650 + end + + # Tracking outdoor-facing office walls. + walls = [] - # Add "Foundation" as outside boundary condition to slabs, WITHOUT adding - # any other KIVA-related objects. model.getSurfaces.each do |s| - next unless s.isGroundSurface - next unless s.surfaceType.downcase == "floor" + next unless s.surfaceType.downcase == "wall" - expect(s.setOutsideBoundaryCondition("Foundation")).to be true + walls << s if s.outsideBoundaryCondition.downcase == "outdoors" end - argh = {} - argh[:option ] = "(non thermal bridging)" - argh[:gen_kiva] = true + expect(walls.size).to eq(8) + + walls.each do |s| + 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 eq("WALL-1") + expect(c.layers.size).to eq(4) + expect(c.layers[0].nameString).to eq("WD01") # RSi 0.165 + expect(c.layers[1].nameString).to eq("PW03") # RSI 0.110 + expect(c.layers[2].nameString).to eq("IN02") # RSi 2.090 + expect(c.layers[3].nameString).to eq("GP01") # RSi 0.079 + end + + argh = { option: "poor (BETBG)" } json = TBD.process(model, argh) expect(json).to be_a(Hash) @@ -9212,919 +9106,939 @@ expect(TBD.status).to be_zero expect(TBD.logs).to be_empty expect(surfaces).to be_a(Hash) - expect(surfaces.size).to eq(56) + expect(surfaces.size).to eq(40) expect(io).to be_a(Hash) expect(io).to have_key(:edges) - expect(io[:edges].size).to eq(106) + expect(io[:edges].size).to eq(47) - slabs = 0 + doors = [] + derated = [] - surfaces.each do |id, s| - next unless s.key?(:kiva) + ids = { a: "LEFT-1", + b: "RIGHT-1", + c: "FRONT-1", + d: "BACK-1", + e: "C1-1", # ceiling below plenum/attic + f: "C2-1", # " + g: "C3-1", # " + h: "C4-1", # " + i: "C5-1" # " + }.freeze - slabs += 1 - expect(s).to have_key(:exposed) - slab = model.getSurfaceByName(id) - expect(slab).to_not be_empty - slab = slab.get + surfaces.each do |id, surface| + expect(surface).to have_key(:type) + expect(surface).to have_key(:conditioned) + next unless surface[:conditioned] + next unless surface.key?(:edges) - expect(slab.adjacentFoundation).to_not be_empty - perimeter = slab.surfacePropertyExposedFoundationPerimeter - expect(perimeter).to_not be_empty - perimeter = perimeter.get + doors += surface[:doors].values if surface.key?(:doors) - per = perimeter.totalExposedPerimeter - expect(per).to_not be_empty - per = per.get - expect((per - s[:exposed]).abs).to be_within(TOL).of(0) + derated << id + expect(ids).to have_value(id) + end - expect(per).to be_within(TOL).of( 8.81) if id == "Small office 1 Floor" - expect(per).to be_within(TOL).of( 8.21) if id == "Utility 1 Floor" - expect(per).to be_within(TOL).of(12.59) if id == "Open area 1 Floor" - expect(per).to be_within(TOL).of( 6.95) if id == "Entry way Floor" + expect(derated.size).to eq(ids.size) + expect(doors.size).to eq(2) + + # Side-testing glass door detection. + doors.each do |door| + expect(door).to have_key(:u) + expect(door).to have_key(:glazed) + expect(door[:glazed]).to be true + expect(door[:u]).to be_a(Numeric) + expect(door[:u]).to be_within(TOL).of(6.54) end - expect(slabs).to eq(4) + # Testing plenum/attic surfaces. + plnum_floors = [] + derated_floors = [] - file = File.join(__dir__, "files/osms/out/seb_KIVA4.osm") - model.save(file, true) + surfaces.each do |id, surface| + expect(surface).to have_key(:space) + next unless surface[:space] == plnum + next unless surface[:type ] == :floor + expect(derated).to_not include(id) + expect(flr_ids).to include(id) + plnum_floors << id + next unless surface.key?(:heatloss) - # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # - # Recover KIVA-populated model and re- set/gen KIVA. - argh = {} - argh[:option ] = "(non thermal bridging)" - argh[:gen_kiva ] = true - argh[:reset_kiva] = true + derated_floors << id if surface.key?(:heatloss) + end - 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.info?).to be true - expect(TBD.logs.size).to eq(1) - expect(TBD.logs.first[:message]).to include("Purged KIVA objects from ") - expect(surfaces).to be_a(Hash) - expect(surfaces.size).to eq(56) - expect(io).to be_a(Hash) - expect(io).to have_key(:edges) - expect(io[:edges].size).to eq(106) + # None are derated, i.e. plenum more akin to an UNCONDITIONED attic. + expect(plnum_floors.size).to eq(5) + expect(derated_floors).to be_empty - slabs = 0 + # Plenum floors are not derated, yet the adjacent ceiling below should be. + derated_ceilings = [] - # Same outcome as "seb_KIVA4.osm". - surfaces.each do |id, s| - next unless s.key?(:kiva) + plnum_floors.each do |id| + expect(surfaces[id]).to have_key(:boundary) + b = surfaces[id][:boundary] - slabs += 1 - expect(s).to have_key(:exposed) - slab = model.getSurfaceByName(id) - expect(slab).to_not be_empty - slab = slab.get + expect(surfaces).to have_key(b) + expect(surfaces[b]).to have_key(:heatloss) + expect(surfaces[b]).to have_key(:conditioned) + expect(surfaces[b]).to have_key(:space) + expect(surfaces[b][:conditioned]).to be true + expect(surfaces[b][:space]).to_not eq(plnum) - expect(slab.adjacentFoundation).to_not be_empty - perimeter = slab.surfacePropertyExposedFoundationPerimeter - expect(perimeter).to_not be_empty - perimeter = perimeter.get + expect(ids).to_not include(id) + next if id == "C5-1P" # core space ceiling - per = perimeter.totalExposedPerimeter - expect(per).to_not be_empty - per = per.get - expect((per - s[:exposed]).abs).to be_within(TOL).of(0) + expect(surfaces[b]).to have_key(:ratio) + h = surfaces[b][:heatloss] + expect(h).to be_within(TOL).of(5.79) if id == "C1-1P" + expect(h).to be_within(TOL).of(2.89) if id == "C2-1P" + expect(h).to be_within(TOL).of(5.79) if id == "C3-1P" + expect(h).to be_within(TOL).of(2.89) if id == "C4-1P" - expect(per).to be_within(TOL).of( 8.81) if id == "Small office 1 Floor" - expect(per).to be_within(TOL).of( 8.21) if id == "Utility 1 Floor" - expect(per).to be_within(TOL).of(12.59) if id == "Open area 1 Floor" - expect(per).to be_within(TOL).of( 6.95) if id == "Entry way Floor" + derated_ceilings << id end - expect(slabs).to eq(4) + expect(derated_ceilings.size).to eq(4) - # Forward-translating/running either "seb_KIVA4.osm" or "seb_KIVA5.osm" - # would yield the same simulation results. - file = File.join(__dir__, "files/osms/out/seb_KIVA5.osm") - model.save(file, true) + surfaces.each do |id, surface| + next unless surface.key?(:edges) + expect(ids).to have_value(id) + expect(surface).to have_key(:heatloss) + next if id == ids[:i] - # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # - # Recover KIVA-populated model and re-gen KIVA ... WITHOUT resetting KIVA. + expect(surface).to have_key(:ratio) + h = surface[:heatloss] + s = model.getSurfaceByName(id) + expect(s).to_not be_empty + s = s.get + expect(s.nameString).to eq(id) + expect(s.isConstructionDefaulted).to be false + expect(s.construction.get.nameString).to include(" tbd") + expect(h).to be_within(TOL).of( 0.00) if id == "C5-1" + expect(h).to be_within(TOL).of(64.92) if id == "FRONT-1" + end + end + + it "can handle TDDs" do + translator = OpenStudio::OSVersion::VersionTranslator.new + version = OpenStudio.openStudioVersion.split(".").join.to_i TBD.clean! - argh = {} - argh[:option ] = "(non thermal bridging)" - argh[:gen_kiva] = true - 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.error?).to be true - expect(TBD.logs.size).to eq(1) - expect(TBD.logs.first[:message]).to include("Exiting - KIVA objects in ") - expect(surfaces).to be_a(Hash) - expect(surfaces.size).to eq(56) - expect(io).to be_a(Hash) - expect(io).to have_key(:edges) - expect(io[:edges].size).to eq(106) + methods = OpenStudio::Model::Model.instance_methods + methods = methods.select { |m| m.to_s.downcase.include?("tubular") } + methods.map! { |m| m.to_s.downcase } + + types = OpenStudio::Model::SubSurface.validSubSurfaceTypeValues + expect(types).to include("TubularDaylightDome") + expect(types).to include("TubularDaylightDiffuser") + + 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 + + # As of v3.3.0, OpenStudio SDK (fully) supports Tubular Daylighting Devices: + # + # https://bigladdersoftware.com/epx/docs/9-6/input-output-reference/ + # group-daylighting.html#daylightingdevicetubular + # + # https://openstudio-sdk-documentation.s3.amazonaws.com/cpp/ + # OpenStudio-3.3.0-doc/model/html/ + # classopenstudio_1_1model_1_1_daylighting_device_tubular.html + # + # For SDK versions >= v3.3.0, testing new TDD methods. + unless version < 330 + expect(methods).to_not be_empty + valid = methods.any? { |method| method.include?("tubular") } + expect(valid).to be true + + # Simple Glazing constructions for both dome & diffuser. + fen = OpenStudio::Model::Construction.new(model) + fen.setName("tubular_fen") + + glazing = OpenStudio::Model::SimpleGlazing.new(model) + glazing.setName("tubular_glazing") + expect(glazing.setUFactor( 6.00)).to be true + expect(glazing.setSolarHeatGainCoefficient(0.50)).to be true + expect(glazing.setVisibleTransmittance( 0.70)).to be true + + layers = OpenStudio::Model::MaterialVector.new + layers << glazing + expect(fen.setLayers(layers)).to be true + + # Tube walls. + construction = OpenStudio::Model::Construction.new(model) + construction.setName("tube_construction") + + interior = OpenStudio::Model::StandardOpaqueMaterial.new(model) + interior.setName("tube_wall") + expect(interior.setRoughness( "MediumRough")).to be true + expect(interior.setThickness( 0.0126)).to be true + expect(interior.setConductivity( 0.1600)).to be true + expect(interior.setDensity( 784.9000)).to be true + expect(interior.setSpecificHeat( 830.0000)).to be true + expect(interior.setThermalAbsorptance(0.9000)).to be true + expect(interior.setSolarAbsorptance( 0.9000)).to be true + expect(interior.setVisibleAbsorptance(0.9000)).to be true + + layers = OpenStudio::Model::MaterialVector.new + layers << interior + expect(construction.setLayers(layers)).to be true + + # Host spaces & surfaces. + sp1 = "Zone1 Office" + sp2 = "Zone2 Fine Storage" + z = "Zone2 Fine Storage ZN" + s1 = "Office Roof" # Office surface hosting new TDD diffuser + s2 = "Office Roof Reversed" # FineStorage floor, above office + s3 = "Fine Storage Roof" # FineStorage surface hosting new TDD dome + + # Fetch host spaces & surfaces. + office = model.getSpaceByName(sp1) + expect(office).to_not be_empty + office = office.get + + storage = model.getSpaceByName(sp2) + expect(storage).to_not be_empty + storage = storage.get - # Without a resetKIVA request, TBD exits with 1x error message. - surfaces.values.each { |surface| expect(surface).to_not have_key(:kiva) } + zone = storage.thermalZone + expect(zone).to_not be_empty + zone = zone.get + expect(zone.nameString).to eq(z) - # As the initial model already has valid & complete KIVA inputs, one - # obtains the same outcome as "seb_KIVA4.osm" & "seb_KIVA5.osm". - file = File.join(__dir__, "files/osms/out/seb_KIVA6.osm") - model.save(file, true) - end + ceiling = model.getSurfaceByName(s1) + expect(ceiling).to_not be_empty + ceiling = ceiling.get - it "can purge KIVA objects" do - translator = OpenStudio::OSVersion::VersionTranslator.new - TBD.clean! + sp = ceiling.space + expect(sp).to_not be_empty + sp = sp.get + expect(sp).to eq(office) - file = File.join(__dir__, "files/osms/out/seb_KIVA.osm") - path = OpenStudio::Path.new(file) - model = translator.loadModel(path) - expect(model).to_not be_empty - model = model.get + floor = model.getSurfaceByName(s2) + expect(floor).to_not be_empty + floor = floor.get - expect(model.foundationKivaSettings).to be_empty - expect(model.getSurfacePropertyExposedFoundationPerimeters.size).to eq(1) - expect(model.getFoundationKivas.size).to eq(4) + sp = floor.space + expect(sp).to_not be_empty + sp = sp.get + expect(sp).to eq(storage) - adjacents = 0 - foundation = nil + adj = ceiling.adjacentSurface + expect(adj).to_not be_empty + adj = adj.get + expect(adj).to eq(floor) - model.getSurfaces.each do |surface| - next unless surface.isGroundSurface - next if surface.adjacentFoundation.empty? + adj = floor.adjacentSurface + expect(adj).to_not be_empty + adj = adj.get + expect(adj).to eq(ceiling) - adjacents += 1 - foundation = surface.adjacentFoundation.get - expect(surface.surfacePropertyExposedFoundationPerimeter).to_not be_empty - expect(surface.outsideBoundaryCondition.downcase).to eq("foundation") - end + roof = model.getSurfaceByName(s3) + expect(roof).to_not be_empty + roof = roof.get - expect(adjacents).to eq(1) - expect(foundation).to be_a(OpenStudio::Model::FoundationKiva) + sp = roof.space + expect(sp).to_not be_empty + sp = sp.get + expect(sp).to eq(storage) - # Add 2x custom blocks for testing. - xps = model.getMaterialByName("XPS_38mm") - expect(xps).to_not be_empty - xps = xps.get - expect(foundation.addCustomBlock(xps, 0.1, 0.1, -0.5)).to be true - expect(foundation.addCustomBlock(xps, 0.2, 0.2, -1.5)).to be true + # Setting heights & Z-axis coordinates. + ceiling_Z = ceiling.centroid.z + roof_Z = roof.centroid.z + length = roof_Z - ceiling_Z + totalLength = length + 0.7 + dome_Z = ceiling_Z + totalLength - blocks = foundation.customBlocks - expect(blocks).to_not be_empty + # A new, 1mx1m diffuser subsurface in Office. + os_v = OpenStudio::Point3dVector.new + os_v << OpenStudio::Point3d.new( 11.0, 4.0, ceiling_Z) + os_v << OpenStudio::Point3d.new( 11.0, 5.0, ceiling_Z) + os_v << OpenStudio::Point3d.new( 10.0, 5.0, ceiling_Z) + os_v << OpenStudio::Point3d.new( 10.0, 4.0, ceiling_Z) - blocks.each { |block| expect(block.material).to eq(xps) } + diffuser = OpenStudio::Model::SubSurface.new(os_v, model) + diffuser.setName("diffuser") + expect(diffuser.setConstruction(fen)).to be true + expect(diffuser.setSubSurfaceType("TubularDaylightDiffuser")).to be true + expect(diffuser.setSurface(ceiling)).to be true + expect(diffuser.uFactor).to_not be_empty + expect(diffuser.uFactor.get).to be_within(0.1).of(6.0) - # Purge. - expect(TBD.resetKIVA(model, "Ground")).to be true - expect(model.foundationKivaSettings).to be_empty - expect(model.getSurfacePropertyExposedFoundationPerimeters).to be_empty - expect(model.getFoundationKivas).to be_empty - expect(TBD.info?).to be true - expect(TBD.logs.size).to eq(1) - expect(TBD.logs.first[:message]).to include("Purged KIVA objects from ") + # A new, 1mx1m dome subsurface above Fine Storage roof. + os_v = OpenStudio::Point3dVector.new + os_v << OpenStudio::Point3d.new( 11.0, 4.0, dome_Z) + os_v << OpenStudio::Point3d.new( 11.0, 5.0, dome_Z) + os_v << OpenStudio::Point3d.new( 10.0, 5.0, dome_Z) + os_v << OpenStudio::Point3d.new( 10.0, 4.0, dome_Z) - model.getSurfaces.each do |surface| - next unless surface.isGroundSurface + dome = OpenStudio::Model::SubSurface.new(os_v, model) + dome.setName("dome") + expect(dome.setConstruction(fen)).to be true + expect(dome.setSubSurfaceType("TubularDaylightDome")).to be true + expect(dome.setSurface(roof)).to be true + expect(dome.uFactor).to_not be_empty + expect(dome.uFactor.get).to be_within(0.1).of(6.0) - expect(surface.adjacentFoundation).to be_empty - expect(surface.surfacePropertyExposedFoundationPerimeter).to be_empty - expect(surface.outsideBoundaryCondition).to eq("Ground") - end + expect(ceiling.tilt).to be_within(TOL).of(diffuser.tilt) + expect(dome.tilt ).to be_within(TOL).of( roof.tilt) - file = File.join(__dir__, "files/osms/out/seb_noKIVA.osm") - model.save(file, true) - end + rsi = 0.28 # default effective TDD RSi (dome to diffuser) + diameter = Math.sqrt(dome.grossArea/Math::PI) * 2 - it "can test Hash inputs" do - translator = OpenStudio::OSVersion::VersionTranslator.new - TBD.clean! + tdd = OpenStudio::Model::DaylightingDeviceTubular.new( + dome, diffuser, construction) - input = {} - schema = "https://github.com/rd2/tbd/blob/master/tbd.schema.json" - file = File.join(__dir__, "files/osms/out/seb2.osm") - path = OpenStudio::Path.new(file) - model = translator.loadModel(path) - expect(model).to_not be_empty - model = model.get + expect(tdd.setDiameter(diameter)).to be true + expect(tdd.setTotalLength(totalLength)).to be true + expect(tdd.addTransitionZone(zone, length)).to be true + cl = OpenStudio::Model::TransitionZoneVector + expect(tdd.transitionZones.class ).to eq(cl) + expect(tdd.numberofTransitionZones).to eq(1) - # Rather than reading a TBD JSON input file (e.g. "json/tbd_seb_n2.json"), - # read in the same content as a Hash. Better for scripted batch runs. - psis = [] - khis = [] - surfaces = [] + expect(tdd.subSurfaceDome).to eq(dome) + expect(tdd.subSurfaceDiffuser).to eq(diffuser) - psi = {} - psi[:id ] = "good" - psi[:parapet ] = 0.500 - psi[:party ] = 0.900 - psis << psi + c = tdd.construction + expect(c.to_LayeredConstruction).to_not be_empty + c = c.to_LayeredConstruction.get - psi = {} - psi[:id ] = "compliant" - psi[:rimjoist ] = 0.300 - psi[:parapet ] = 0.325 - psi[:fenestration ] = 0.350 - psi[:corner ] = 0.450 - psi[:balcony ] = 0.500 - psi[:party ] = 0.500 - psi[:grade ] = 0.450 - psis << psi + expect(c.nameString).to eq(construction.nameString) + expect(tdd.diameter).to be_within(TOL).of(diameter) + expect(tdd.effectiveThermalResistance).to be_within(TOL).of(rsi) - khi = {} - khi[:id ] = "column" - khi[:point ] = 0.500 - khis << khi + pth = File.join(__dir__, "files/osms/out/tdd_warehouse.osm") + model.save(pth, true) - khi = {} - khi[:id ] = "support" - khi[:point ] = 0.500 - khis << khi + # Testing if TBD recognizes the TDD as a "skylight" (for derating & UA'). + argh = { option: "poor (BETBG)" } - surface = {} - surface[:id ] = "Entryway Wall 5" - surface[:khis ] = [] - surface[:khis ] << { id: "column", count: 3 } - surface[:khis ] << { id: "support", count: 4 } - surfaces << surface + 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.status.zero?).to be(true) + expect(TBD.logs).to be_empty + expect(surfaces).to be_a(Hash) + expect(surfaces.size).to eq(23) + expect(io).to be_a(Hash) + expect(io).to have_key(:edges) + expect(io[:edges].size).to eq(304) - input[:schema ] = schema - input[:description] = "testing JSON surface KHI entries" - input[:psis ] = psis - input[:khis ] = khis - input[:surfaces ] = surfaces + # Both diffuser and parent (office) ceiling are stored as TBD 'surfaces'. + expect(surfaces).to have_key(s1) + surface = surfaces[s1] + expect(surface).to have_key(:skylights) + expect(surface[:skylights].size).to eq(1) + expect(surface[:skylights]).to have_key("diffuser") - # Export to file. Both files should be the same. - out = JSON.pretty_generate(input) - pth = File.join(__dir__, "../json/tbd_seb_n2.out.json") - File.open(pth, "w") { |pth| pth.puts out } - initial = File.join(__dir__, "../json/tbd_seb_n2.json") - expect(FileUtils).to be_identical(initial, pth) + skylight = surface[:skylights]["diffuser"] + expect(skylight).to be_a(Hash) + expect(skylight).to have_key(:u) + expect(skylight[:u]).to be_a(Numeric) + expect(skylight[:u]).to be_within(TOL).of(1/rsi) + # ... yet TBD only derates constructions of opaque surfaces in CONDITIONED + # spaces if: + # + # (i) facing outdoors or + # (ii) facing UNCONDITIONED spaces like attics (see psi.rb). + # + # Here, the ceiling is not tagged by TBD as a deratable surface. + # Diffuser edges are therefore not logged in TBD's 'edges'. + expect(surface).to_not have_key(:heatloss) + expect(surface).to_not have_key(:ratio) - argh = {} - argh[:option ] = "(non thermal bridging)" - argh[:io_path ] = input - argh[:schema_path ] = File.join(__dir__, "../tbd.schema.json") + # Only edges of the dome (linked to the Fine Storage roof) are stored. + io[:edges].each do |edge| + expect(edge).to be_a(Hash) + expect(edge).to have_key(:surfaces) + expect(edge[:surfaces]).to be_a(Array) + + edge[:surfaces].each do |id| + expect(id).to eq("dome") if ["dome", "diffuser"].include?(id) + end + end + + expect(surfaces).to have_key(s3) + surface = surfaces[s3] + + expect(surface).to have_key(:skylights) + expect(surface[:skylights].size).to eq(15) # original 14x +1 + expect(surface[:skylights]).to have_key("dome") - 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.status).to be_zero - expect(TBD.logs).to be_empty - expect(surfaces).to be_a(Hash) - expect(surfaces.size).to eq(56) - expect(io).to be_a(Hash) - expect(io).to have_key(:edges) - expect(io[:edges].size).to eq(106) + surface[:skylights].each do |i, skylight| + expect(skylight).to have_key(:u) + expect(skylight[:u]).to be_a(Numeric) + expect(skylight[:u]).to be_within(TOL).of(6.64) unless i == "dome" + expect(skylight[:u]).to be_within(TOL).of(1/rsi) if i == "dome" + end - surfaces.values.each do |surface| - next unless surface.key?(:ratio) + expect(surface).to have_key(:heatloss) + expect(surface[:heatloss]).to be_within(TOL).of(89.16) # +2.0 W/K + expect(io[:edges].size).to eq(304) # 4x extra edges for dome only - expect(surface[:heatloss]).to be_within(TOL).of(3.5) - end - end + out = JSON.pretty_generate(io) + outP = File.join(__dir__, "../json/tbd_warehouse15.out.json") + File.open(outP, "w") { |outP| outP.puts out } - it "can check for attics vs plenums" do - translator = OpenStudio::OSVersion::VersionTranslator.new - TBD.clean! - # Outdoor-facing surfaces of UNCONDITIONED spaces are never derated by TBD. - # Yet determining whether an OpenStudio space should be considered - # UNCONDITIONED (e.g. an attic), rather than INDIRECTLYCONDITIONED - # (e.g. a plenum) can be tricky depending on the (incomplete) state of - # development of an OpenStudio model. In determining the conditioning - # status of each OpenStudio space, TBD relies on OSut methods: - # - 'setpoints(space)': applicable space heating/cooling setpoints - # - 'heatingTemperatureSetpoints?': ANY space holding heating setpoints? - # - 'coolingTemperatureSetpoints?': ANY space holding cooling setpoints? - # - # Users can consult the online OSut API documentation to know more. + # Re-use the exported file as input for another warehouse. + model2 = translator.loadModel(pth) + expect(model2).to_not be_empty + model2 = model2.get - # Small office test case (UNCONDITIONED attic). - file = File.join(__dir__, "files/osms/in/smalloffice.osm") - path = OpenStudio::Path.new(file) - model = translator.loadModel(path) - expect(model).to_not be_empty - model = model.get - attic = model.getSpaceByName("Attic") - expect(attic).to_not be_empty - attic = attic.get + argh[:schema_path] = File.join(__dir__, "../tbd.schema.json") + argh[:io_path ] = File.join(__dir__, "../json/tbd_warehouse15.out.json") - model.getSpaces.each do |space| - next if space == attic + json2 = TBD.process(model2, argh) + expect(json2).to be_a(Hash) + expect(json2).to have_key(:io) + expect(json2).to have_key(:surfaces) + io2 = json2[:io ] + surfaces = json2[:surfaces] + expect(TBD.status).to be_zero + expect(TBD.logs).to be_empty + expect(surfaces).to be_a(Hash) + expect(surfaces.size).to eq(23) + expect(io).to be_a(Hash) + expect(io).to have_key(:edges) + expect(io[:edges].size).to eq(304) - zone = space.thermalZone - expect(zone).to_not be_empty - zone = zone.get - heat = TBD.maxHeatScheduledSetpoint(zone) - cool = TBD.minCoolScheduledSetpoint(zone) + # Now mimic (again) the export functionality of the measure. Both output + # files should be the same. + out2 = JSON.pretty_generate(io2) + outP2 = File.join(__dir__, "../json/tbd_warehouse16.out.json") + File.open(outP2, "w") { |outP2| outP2.puts out2 } + expect(FileUtils).to be_identical(outP, outP2) + else + expect(methods).to be_empty - expect(heat[:spt]).to be_within(TOL).of(21.11) - expect(cool[:spt]).to be_within(TOL).of(23.89) - expect(heat[:dual]).to be true - expect(cool[:dual]).to be true + # SDK pre-v3.3.0 testing on one of the existing skylights, as a tubular + # TDD dome (without a complete TDD object). + nom = "FineStorage_skylight_5" + sky5 = model.getSubSurfaceByName(nom) + expect(sky5).to_not be_empty + sky5 = sky5.get + expect(sky5.subSurfaceType.downcase).to eq("skylight") + name = "U 1.17 SHGC 0.39 Simple Glazing Skylight U-1.17 SHGC 0.39 2" - expect(space.partofTotalFloorArea).to be true - expect(TBD.plenum?(space)).to be false - expect(TBD.unconditioned?(space)).to be false - expect(TBD.setpoints(space)[:heating]).to be_within(TOL).of(21.11) - expect(TBD.setpoints(space)[:cooling]).to be_within(TOL).of(23.89) - end + skylight = sky5.construction + expect(skylight).to_not be_empty + expect(skylight.get.nameString).to eq(name) - zone = attic.thermalZone - expect(zone).to_not be_empty - zone = zone.get - heat = TBD.maxHeatScheduledSetpoint(zone) - cool = TBD.minCoolScheduledSetpoint(zone) + expect(sky5.setSubSurfaceType("TubularDaylightDome")).to be true + skylight = sky5.construction + expect(skylight).to_not be_empty + expect(skylight.get.nameString).to eq("Typical Interior Window") + # Weird to see "Typical Interior Window" as a suitable construction for a + # tubular skylight dome, but that's the assigned default construction in + # the DOE prototype warehouse model. - expect(heat[:spt ]).to be_nil - expect(cool[:spt ]).to be_nil - expect(heat[:dual]).to be false - expect(cool[:dual]).to be false + roof = model.getSurfaceByName("Fine Storage Roof") + expect(roof).to_not be_empty + roof = roof.get - expect(TBD.plenum?(attic)).to be false - expect(TBD.unconditioned?(attic)).to be true - expect(TBD.setpoints(attic)[:heating]).to be_nil - expect(TBD.setpoints(attic)[:cooling]).to be_nil - expect(attic.partofTotalFloorArea).to be false - expect(TBD.status).to be_zero + # Testing if TBD recognizes it as a "skylight" (for derating & UA'). + argh = { option: "poor (BETBG)" } - argh = { option: "code (Quebec)" } - json = TBD.process(model, argh) - expect(TBD.status).to be_zero - expect(json).to be_a(Hash) - expect(json).to have_key(:io) - expect(json).to have_key(:surfaces) - io = json[:io ] - surfaces = json[:surfaces] - expect(surfaces).to be_a(Hash) - expect(surfaces.size).to eq(43) - expect(io).to have_key(:edges) - expect(io[:edges].size).to eq(105) + 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.status).to be_zero + expect(TBD.logs).to be_empty + expect(surfaces).to be_a(Hash) + expect(surfaces.size).to eq(23) + expect(io).to be_a(Hash) + expect(io).to have_key(:edges) + expect(io[:edges].size).to eq(300) - surfaces.each do |id, surface| - next unless id.include?("_roof_") + expect(surfaces).to have_key("Fine Storage Roof") + surface = surfaces["Fine Storage Roof"] - expect(id).to include("Attic") - expect(surface).to_not have_key(:ratio) - expect(surface).to have_key(:conditioned) - expect(surface).to have_key(:deratable) - expect(surface[:conditioned]).to be false - expect(surface[:deratable]).to be false + if surface.key?(:skylights) + expect(surface[:skylights]).to have_key(nom) + + surface[:skylights].each do |i, skylight| + expect(skylight).to have_key(:u) + expect(skylight[:u]).to be_a(Numeric) + expect(skylight[:u]).to be_within(TOL).of(6.64) unless i == nom + expect(skylight[:u]).to be_within(TOL).of(7.18) if i == nom + # So TBD processes any subsurface perimeter, whether skylight, TDD, + # etc. And it retrieves a calculated U-factor for TBD's UA' trade-off + # calculations. A follow-up OpenStudio-launched EnergyPlus simulation + # reveals that, despite having an incomplete TDD setup: + # + # dome > tube > diffuser + # + # ... EnergyPlus will proceed without warning(s) for OpenStudio + # < v3.3.0. Results reflect an expected increase in heating energy + # (Climate Zone 7), due to the poor(er) performance of the dome. + end + end end + end - # Now tag attic as an INDIRECTLYCONDITIONED space (linked to "Core_ZN"). - file = File.join(__dir__, "files/osms/in/smalloffice.osm") + it "can handle TDDs in attics (false plenums)" do + translator = OpenStudio::OSVersion::VersionTranslator.new + version = OpenStudio.openStudioVersion.split(".").join.to_i + TBD.clean! + + file = File.join(__dir__, "files/osms/in/5Zone_2.osm") path = OpenStudio::Path.new(file) model = translator.loadModel(path) expect(model).to_not be_empty model = model.get - attic = model.getSpaceByName("Attic") - expect(attic).to_not be_empty - attic = attic.get - - key = "indirectlyconditioned" - val = "Core_ZN" - expect(attic.additionalProperties.setFeature(key, val)).to be true - expect(TBD.plenum?(attic)).to be false - expect(TBD.unconditioned?(attic)).to be false - expect(TBD.setpoints(attic)[:heating]).to be_within(TOL).of(21.11) - expect(TBD.setpoints(attic)[:cooling]).to be_within(TOL).of(23.89) - expect(TBD.status).to be_zero - - 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) - io = json[:io ] - surfaces = json[:surfaces] - expect(TBD.status).to be_zero - expect(surfaces).to be_a(Hash) - expect(surfaces.size).to eq(43) - expect(io).to have_key(:edges) - expect(io[:edges].size).to eq(110) - surfaces.each do |id, surface| - next unless id.include?("_roof_") + # For SDK versions >= v3.3.0, testing new DaylightingTubularDevice methods. + unless version < 330 + # Both dome & diffuser: Simple Glazing constructions. + fen = OpenStudio::Model::Construction.new(model) + fen.setName("tubular_fen") + expect(fen.nameString).to eq("tubular_fen") + expect(fen.layers).to be_empty - expect(id).to include("Attic") - expect(surface).to have_key(:ratio) - expect(surface).to have_key(:conditioned) - expect(surface).to have_key(:deratable) - expect(surface[:conditioned]).to be true - expect(surface[:deratable]).to be true - end + glazing = OpenStudio::Model::SimpleGlazing.new(model) + glazing.setName("tubular_glazing") + expect(glazing.nameString).to eq("tubular_glazing") + expect(glazing.setUFactor(6.0)).to be true + expect(glazing.setSolarHeatGainCoefficient(0.50)).to be true + expect(glazing.setVisibleTransmittance(0.70)).to be true - expect(attic.additionalProperties.resetFeature(key)).to be true + layers = OpenStudio::Model::MaterialVector.new + layers << glazing + expect(fen.setLayers(layers)).to be true + expect(fen.layers.size).to eq(1) + expect(fen.layers[0].handle.to_s).to eq(glazing.handle.to_s) + expect(fen.uFactor).to_not be_empty + expect(fen.uFactor.get).to be_within(0.1).of(6.0) - # Adding a sub surface between UNCONDITIONED Attic & CONDITIONED Core. - file = File.join(__dir__, "files/osms/in/smalloffice.osm") - path = OpenStudio::Path.new(file) - model = translator.loadModel(path) - expect(model).to_not be_empty - model = model.get + # Tube walls. + construction = OpenStudio::Model::Construction.new(model) + construction.setName("tube_construction") + expect(construction.nameString).to eq("tube_construction") + expect(construction.layers).to be_empty - floor = model.getSurfaceByName("Attic_floor_core") - expect(floor).to_not be_empty - floor = floor.get + interior = OpenStudio::Model::StandardOpaqueMaterial.new(model) + interior.setName("tube_wall") + expect(interior.nameString).to eq("tube_wall") + expect(interior.setRoughness("MediumRough")).to be true + expect(interior.setThickness(0.0126)).to be true + expect(interior.setConductivity(0.16)).to be true + expect(interior.setDensity(784.9)).to be true + expect(interior.setSpecificHeat(830)).to be true + expect(interior.setThermalAbsorptance(0.9)).to be true + expect(interior.setSolarAbsorptance(0.9)).to be true + expect(interior.setVisibleAbsorptance(0.9)).to be true - ceiling = floor.adjacentSurface - expect(ceiling).to_not be_empty - ceiling = ceiling.get + layers = OpenStudio::Model::MaterialVector.new + layers << interior + expect(construction.setLayers(layers)).to be true + expect(construction.layers.size).to eq(1) + expect(construction.layers[0].handle.to_s).to eq(interior.handle.to_s) - sub = {} - sub[:id ] = "attic trap door" - sub[:type ] = "Door" - sub[:assembly] = TBD.genConstruction(model, {type: :door}) - sub[:width ] = 1.0 - sub[:height ] = 1.0 - expect(TBD.addSubs(floor, sub, false, true, true)).to be true - expect(TBD.addSubs(ceiling, sub, false, true, false)).to be true - expect(floor.subSurfaces.size).to eq(1) - expect(ceiling.subSurfaces.size).to eq(1) - trap = floor.subSurfaces.first - door = ceiling.subSurfaces.first - expect(trap.setAdjacentSubSurface(door)).to be true - expect(door.setAdjacentSubSurface(trap)).to be true - expect(trap.adjacentSubSurface).to_not be_empty - expect(door.adjacentSubSurface).to_not be_empty - expect(trap.adjacentSubSurface.get).to eq(door) - expect(door.adjacentSubSurface.get).to eq(trap) + # Host spaces & surfaces. + sp1 = "SPACE5-1" + sp2 = "PLENUM-1" + z = "PLENUM-1 Thermal Zone" + s1 = "C5-1" # sp1 surface hosting new TDD diffuser + s2 = "C5-1P" # plenum surface, above sp1 + s3 = "TOP-1" # plenum surface hosting new TDD dome - argh = { option: "code (Quebec)" } - json = TBD.process(model, argh) - expect(TBD.status).to be_zero - expect(json).to be_a(Hash) - expect(json).to have_key(:io) - expect(json).to have_key(:surfaces) - io = json[:io ] - surfaces = json[:surfaces] - expect(surfaces).to be_a(Hash) - expect(surfaces.size).to eq(43) - expect(io).to have_key(:edges) - expect(io[:edges].size).to eq(109) + # Fetch host spaces & surfaces. + space = model.getSpaceByName(sp1) + expect(space).to_not be_empty + space = space.get - file = File.join(__dir__, "files/osms/out/trapdoor.osm") - model.save(file, true) + plenum = model.getSpaceByName(sp2) + expect(plenum).to_not be_empty + plenum = plenum.get - # Adding skylights/wells. - file = File.join(__dir__, "files/osms/in/smalloffice.osm") - path = OpenStudio::Path.new(file) - model = translator.loadModel(path) - expect(model).to_not be_empty - model = model.get + zone = plenum.thermalZone + expect(zone).to_not be_empty + zone = zone.get + expect(zone.nameString).to eq(z) - srr = 0.05 - gra = TBD.grossRoofArea(model.getSpaces) - tm2 = srr * gra - rm2 = TBD.addSkyLights(model.getSpaces, {area: tm2}) - expect(TBD.status).to be_zero - expect(rm2.round(2)).to eq(gra.round(2)) + ceiling = model.getSurfaceByName(s1) + expect(ceiling).to_not be_empty + ceiling = ceiling.get + sp = ceiling.space + expect(sp).to_not be_empty + sp = sp.get + expect(sp).to eq(space) - argh = {} - argh[:option ] = "efficient (BETBG)" - argh[:uprate_walls] = true - argh[:uprate_roofs] = true - argh[:wall_option ] = "ALL wall constructions" - argh[:roof_option ] = "ALL roof constructions" - argh[:wall_ut ] = 0.215 # NECB 2020 CZ7A (RSi 4.65 / R26) - argh[:roof_ut ] = 0.121 # NECB 2020 CZ7A (RSi 8.26 / R47) - json = TBD.process(model, argh) - expect(TBD.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) - io = json[:io ] - surfaces = json[:surfaces] - expect(surfaces).to be_a(Hash) - expect(surfaces.size).to eq(79) - expect(io).to have_key(:edges) - expect(io[:edges].size).to eq(173) + floor = model.getSurfaceByName(s2) + expect(floor).to_not be_empty + floor = floor.get + sp = floor.space + expect(sp).to_not be_empty + sp = sp.get + expect(sp).to eq(plenum) - file = File.join(__dir__, "files/osms/out/office_attic_sky.osm") - model.save(file, true) + adj = ceiling.adjacentSurface + expect(adj).to_not be_empty + adj = adj.get + expect(adj).to eq(floor) - TBD.clean! + adj = floor.adjacentSurface + expect(adj).to_not be_empty + adj = adj.get + expect(adj).to eq(ceiling) - # -- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- -- # - # 5Zone_2 test case (as INDIRECTLYCONDITIONED plenum). - plenum_walls = [] - plnum_walls = ["WALL-1PB", "WALL-1PF", "WALL-1PL", "WALL-1PR"] - other_ceilings = ["C1-1", "C2-1", "C3-1", "C4-1", "C5-1"] + roof = model.getSurfaceByName(s3) + expect(roof).to_not be_empty + roof = roof.get + sp = roof.space + expect(sp).to_not be_empty + sp = sp.get + expect(sp).to eq(plenum) - file = File.join(__dir__, "files/osms/in/5Zone_2.osm") - path = OpenStudio::Path.new(file) - model = translator.loadModel(path) - expect(model).to_not be_empty - model = model.get + # Setting heights & Z-axis coordinates. + ceiling_Z = ceiling.centroid.z + roof_Z = roof.centroid.z + length = roof_Z - ceiling_Z + totalLength = length + 0.5 + dome_Z = ceiling_Z + totalLength - # The model has valid thermostats. - heated = TBD.heatingTemperatureSetpoints?(model) - cooled = TBD.coolingTemperatureSetpoints?(model) - expect(heated).to be true - expect(cooled).to be true + # A new, 1mx1m diffuser subsurface in space ceiling. + os_v = OpenStudio::Point3dVector.new + os_v << OpenStudio::Point3d.new( 15.75, 7.15, ceiling_Z) + os_v << OpenStudio::Point3d.new( 15.75, 8.15, ceiling_Z) + os_v << OpenStudio::Point3d.new( 14.75, 8.15, ceiling_Z) + os_v << OpenStudio::Point3d.new( 14.75, 7.15, ceiling_Z) + diffuser = OpenStudio::Model::SubSurface.new(os_v, model) + diffuser.setName("diffuser") + expect(diffuser.setConstruction(fen)).to be true + expect(diffuser.setSubSurfaceType("TubularDaylightDiffuser")).to be true + expect(diffuser.setSurface(ceiling)).to be true + expect(diffuser.uFactor).to_not be_empty + expect(diffuser.uFactor.get).to be_within(0.1).of(6.0) - plnum = model.getSpaceByName("PLENUM-1") - expect(plnum).to_not be_empty - plnum = plnum.get + # A new, 1mx1m dome subsurface above Plenum roof. + os_v = OpenStudio::Point3dVector.new + os_v << OpenStudio::Point3d.new( 15.75, 7.15, dome_Z) + os_v << OpenStudio::Point3d.new( 15.75, 8.15, dome_Z) + os_v << OpenStudio::Point3d.new( 14.75, 8.15, dome_Z) + os_v << OpenStudio::Point3d.new( 14.75, 7.15, dome_Z) + dome = OpenStudio::Model::SubSurface.new(os_v, model) + dome.setName("dome") + expect(dome.setConstruction(fen)).to be true + expect(dome.setSubSurfaceType("TubularDaylightDome")).to be true + expect(dome.setSurface(roof)).to be true + expect(dome.uFactor).to_not be_empty + expect(dome.uFactor.get).to be_within(0.1).of(6.0) - # The plenum is more akin to an UNCONDITIONED attic (no thermostat). - expect(TBD.plenum?(plnum)).to be false - 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 + expect(ceiling.tilt).to be_within(TOL).of(diffuser.tilt) + expect(dome.tilt).to be_within(TOL).of(roof.tilt) - 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) - io = json[:io ] - surfaces = json[:surfaces] - expect(TBD.status).to be_zero - expect(TBD.logs).to be_empty - expect(surfaces).to be_a(Hash) - expect(surfaces.size).to eq(40) - expect(io).to be_a(Hash) - expect(io).to have_key(:edges) + rsi = 0.28 + diameter = Math.sqrt(dome.grossArea/Math::PI) * 2 - # Plenum "walls" are not derated. - plnum_walls.each do |s| - expect(surfaces).to have_key(s) - expect(surfaces[s][:deratable]).to be false - end + tdd = OpenStudio::Model::DaylightingDeviceTubular.new( + dome, diffuser, construction, diameter, totalLength, rsi) - # "Other" ceilings (i.e. those of conditioned spaces, adjacent to plenum - # "floors") are like insulated attic ceilings, and therefore derated. - other_ceilings.each do |s| - expect(surfaces).to have_key(s) - expect(surfaces[s][:deratable]).to be true - end + expect(tdd.addTransitionZone(zone, length)).to be true + cl = OpenStudio::Model::TransitionZoneVector + expect(tdd.transitionZones.class).to eq(cl) + expect(tdd.numberofTransitionZones).to eq(1) + expect(tdd.totalLength).to be_within(0.001).of(totalLength) - # There are no above-grade "rimjoists" identified by TBD: - expect(io[:edges].count { |edge| edge[:type] == :rimjoist }).to eq(0) - expect(io[:edges].count { |edge| edge[:type] == :gradeconvex }).to eq(8) - expect(io[:edges].count { |edge| edge[:type] == :parapetconvex }).to eq(4) + expect(tdd.subSurfaceDome).to eq(dome) + expect(tdd.subSurfaceDiffuser).to eq(diffuser) + c = tdd.construction + expect(c.to_LayeredConstruction).to_not be_empty + c = c.to_LayeredConstruction.get + expect(c.nameString).to eq(construction.nameString) + expect(tdd.diameter).to be_within(0.001).of(diameter) + expect(tdd.effectiveThermalResistance).to be_within(TOL).of(rsi) - # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # - # Try again, yet first reset the plenum as INDIRECTLYCONDITIONED. - file = File.join(__dir__, "files/osms/in/5Zone_2.osm") - path = OpenStudio::Path.new(file) - model = translator.loadModel(path) - expect(model).to_not be_empty - model = model.get + pth = File.join(__dir__, "files/osms/out/tdd_5Z_test.osm") + model.save(pth, true) - # Ensure the plenum is 'unoccupied', i.e. not part of the total floor area. - plnum = model.getSpaceByName("PLENUM-1") - expect(plnum).to_not be_empty - plnum = plnum.get - expect(plnum.setPartofTotalFloorArea(false)).to be true + # Testing if TBD recognizes the TDD as a "skylight" (for derating & UA'). + argh = { option: "poor (BETBG)" } - key = "indirectlyconditioned" - val = "SPACE5-1" - expect(plnum.additionalProperties.setFeature(key, val)).to be true - expect(TBD.plenum?(plnum)).to be false - expect(TBD.unconditioned?(plnum)).to be false - expect(TBD.setpoints(plnum)[:heating]).to be_within(TOL).of(22.20) - expect(TBD.setpoints(plnum)[:cooling]).to be_within(TOL).of(23.90) - expect(TBD.status).to be_zero + 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.status).to be_zero + expect(TBD.logs).to be_empty + expect(surfaces).to be_a(Hash) + expect(surfaces.size).to eq(40) + expect(io).to be_a(Hash) + expect(io).to have_key(:edges) + expect(io[:edges].size).to eq(51) # 4x extra edges for diffuser - not dome - file = File.join(__dir__, "files/osms/out/z5.osm") - model.save(file, true) + # Both diffuser and parent ceiling are stored as TBD 'surfaces'. + expect(surfaces).to have_key(s1) + surface = surfaces[s1] + expect(surface).to have_key(:skylights) + expect(surface[:skylights].size).to eq(1) + expect(surface[:skylights]).to have_key("diffuser") + skylight = surface[:skylights]["diffuser"] + expect(skylight).to have_key(:u) + expect(skylight[:u]).to be_a(Numeric) + expect(skylight[:u]).to be_within(TOL).of(1/rsi) - 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) - io = json[:io ] - surfaces = json[:surfaces] - expect(TBD.status).to be_zero - expect(TBD.logs).to be_empty - expect(surfaces).to be_a(Hash) - expect(surfaces.size).to eq(40) - expect(io).to be_a(Hash) - expect(io).to have_key(:edges) + # ... yet TBD only derates constructions of opaque surfaces in CONDITIONED + # spaces IF (i) facing outdoors or (ii) facing UNCONDITIONED spaces like + # attics (see psi.rb). Here, the ceiling is tagged by TBD as a deratable + # surface, and hence the diffuser edges are logged in TBD's 'edges'. + expect(surface).to have_key(:ratio) + expect(surface).to have_key(:heatloss) + expect(surface[:heatloss]).to be_within(TOL).of(2.00) # 4x 0.500 W/K - # Plenum "walls" are now derated. - plnum_walls.each do |s| - expect(surfaces).to have_key(s) - expect(surfaces[s][:deratable]).to be true - end + # Only edges of the diffuser (linked to the ceiling) are stored. + io[:edges].each do |edge| + expect(edge).to be_a(Hash) + expect(edge).to have_key(:surfaces) + expect(edge[:surfaces]).to be_a(Array) - # "Other" ceilings (i.e. those of conditioned spaces, adjacent to plenum - # "floors") are now like uninsulated suspended ceilings (no longer derated). - other_ceilings.each do |s| - expect(surfaces).to have_key(s) - expect(surfaces[s][:deratable]).to be false - end + edge[:surfaces].each do |id| + expect(id).to eq("diffuser") if ["dome", "diffuser"].include?(id) + end + end - # Prior to v3.4.0, plenum floors would have been tagged as "rimjoists". No - # longer the case ("ceilings" are caught earlier in the process). - expect(io[:edges].count { |edge| edge[:type] == :ceiling }).to eq(4) - expect(io[:edges].count { |edge| edge[:type] == :rimjoist }).to eq(0) - expect(io[:edges].count { |edge| edge[:type] == :gradeconvex }).to eq(8) - expect(io[:edges].count { |edge| edge[:type] == :parapetconvex }).to eq(4) + expect(surfaces).to have_key(s3) + surface = surfaces[s3] - # There are (very) rare cases of INDIRECTLYCONDITIONED technical spaces - # (above occupied spaces) that have structural "floors" (not e.g. suspended - # ceiling tiles), supporting significant static and dynamic loads (e.g. - # Louis Kahn's Salk Institute). Yet for the vast majority of cases (e.g. - # return air plenums), we see simple suspended ceilings. Their perimeter - # edges do not thermally bridge (or derate) insulated building envelopes. - # - # Prior to v3.4.0, we initially retained a laissez-faire approach with TBD - # regarding floors of INDIRECTLYCONDITIONED spaces (like plenums). Indeed, - # many (older?) OpenStudio models have plenum floors with 'reset' surface - # types ("RoofCeiling"), which was sufficient for TBD to not tag such edges - # as "rimjoists", i.e. intermediate (structural) floor slabs. Sure, TBD - # users could always override this default behaviour by specifying spacetype - # -specific PSI factor sets (JSON inputs), with "rimjoists" of 0 W/K per - # meter. Yet these workarounds necessarily implied additional steps for the - # vast majority of TBD users. As of v3.4.0, the default automated TBD - # outcome is to tag plenum "floors" as "ceilings" (no additional steps). - # - # The flip side is that additional consideration may be required for less - # common cases involving plenums. Take for instance underfloor air supply - # plenums. The carpeted floors building occupants actually walk on are not - # structural concrete slabs (the perimeter edges of which would constitute - # common thermal bridges, i.e. "rimjoists"). By default, TBD will now tag - # the raised floor as a structural "floor" (with associated thermal - # bridging) and instead tag the actual structural slab as "ceiling". - # Although this doesn't sound OK initially, this works out just fine for - # most cases: the "rimjoist" edge may not line up perfectly (vertically), - # but there remains only one per surface (a similar outcome to 'offset' - # masonry shelf angles). Users are always free to curtomize TBD (via - # JSON input) if needed. + expect(surface).to have_key(:skylights) + expect(surface[:skylights]).to_not be_nil + expect(surface[:skylights].size).to eq(1) + expect(surface[:skylights]).to have_key("dome") + skylight = surface[:skylights]["dome"] - # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # - # Test a custom non-0 "ceiling" PSI-factor. - file = File.join(__dir__, "files/osms/out/z5.osm") - path = OpenStudio::Path.new(file) - model = translator.loadModel(path) - expect(model).to_not be_empty - model = model.get + expect(skylight).to have_key(:u) + expect(skylight[:u]).to be_a(Numeric) + expect(skylight[:u]).to be_within(TOL).of(1/rsi) + expect(surface).to_not have_key(:heatloss) + expect(surface).to_not have_key(:ratio) - argh = {} - argh[:option ] = "uncompliant (Quebec)" - argh[:io_path ] = File.join(__dir__, "../json/tbd_z5.json") - argh[:schema_path] = File.join(__dir__, "../tbd.schema.json") + out = JSON.pretty_generate(io) + outP = File.join(__dir__, "../json/tbd_5Z.out.json") + File.open(outP, "w") { |outP| outP.puts out } - 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.status).to be_zero - expect(TBD.logs).to be_empty - expect(surfaces).to be_a(Hash) - expect(surfaces.size).to eq(40) - expect(io).to be_a(Hash) - expect(io).to have_key(:edges) + # Re-use the exported file as input for another 5Z test. + model2 = translator.loadModel(pth) + expect(model2).to_not be_empty + model2 = model2.get - # Plenum "walls" are (still) derated. - plnum_walls.each do |s| - expect(surfaces).to have_key(s) - expect(surfaces[s][:deratable]).to be true - end + argh[:schema_path] = File.join(__dir__, "../tbd.schema.json") + argh[:io_path ] = File.join(__dir__, "../json/tbd_5Z.out.json") - # "Other" ceilings (i.e. those of conditioned spaces, adjacent to plenum - # "floors") are (still) no longer derated. - other_ceilings.each do |s| - expect(surfaces).to have_key(s) - expect(surfaces[s][:deratable]).to be false - end + json2 = TBD.process(model2, argh) + expect(json2).to be_a(Hash) + expect(json2).to have_key(:io) + expect(json2).to have_key(:surfaces) + io2 = json2[:io ] + surfaces = json2[:surfaces] + expect(TBD.status).to be_zero + expect(TBD.logs).to be_empty + expect(surfaces).to be_a(Hash) + expect(surfaces.size).to eq(40) + expect(io2).to be_a(Hash) + expect(io2).to have_key(:edges) + expect(io2[:edges].size).to eq(51) - io[:edges].select { |edge| edge[:type] == :ceiling }.each do |edge| - expect(edge[:psi]).to eq("salk") + # Now mimic (again) the export functionality of the measure. Both output + # files should be the same. + out2 = JSON.pretty_generate(io2) + outP2 = File.join(__dir__, "../json/tbd_5Z_2.out.json") + File.open(outP2, "w") { |outP2| outP2.puts out2 } + expect(FileUtils).to be_identical(outP, outP2) end + end - expect(io[:edges].count { |edge| edge[:type] == :ceiling }).to eq(4) - expect(io[:edges].count { |edge| edge[:type] == :rimjoist }).to eq(0) - expect(io[:edges].count { |edge| edge[:type] == :gradeconvex }).to eq(8) - expect(io[:edges].count { |edge| edge[:type] == :parapetconvex }).to eq(4) + it "can handle TDDs in attics" do + translator = OpenStudio::OSVersion::VersionTranslator.new + version = OpenStudio.openStudioVersion.split(".").join.to_i + TBD.clean! - out = JSON.pretty_generate(io) - file = File.join(__dir__, "../json/tbd_z5.out.json") - File.open(file, "w") { |f| f.puts out } + file = File.join(__dir__, "files/osms/in/smalloffice.osm") + path = OpenStudio::Path.new(file) + model = translator.loadModel(path) + expect(model).to_not be_empty + model = model.get + # For SDK versions >= v3.3.0, testing new DaylightingTubularDevice methods. + unless version < 330 + # Both dome & diffuser: Simple Glazing constructions. + fen = OpenStudio::Model::Construction.new(model) + fen.setName("tubular_fen") + expect(fen.nameString).to eq("tubular_fen") + expect(fen.layers).to be_empty - # --- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- --- # - # The following variations of the 'FullServiceRestaurant' (v3.2.1) are - # snapshots of incremental development of the same model. For each step, - # the tests illustrate how TBD ends up considering the unoccupied space - # (below roof) and how simple variable changes allow users to switch from - # UNCONDITIONED to INDIRECTLYCONDITIONED (or vice versa). - unless OpenStudio.openStudioVersion.split(".").join.to_i < 321 - TBD.clean! + glazing = OpenStudio::Model::SimpleGlazing.new(model) + glazing.setName("tubular_glazing") + expect(glazing.nameString).to eq("tubular_glazing") + expect(glazing.setUFactor(6.0)).to be true + expect(glazing.setSolarHeatGainCoefficient(0.50)).to be true + expect(glazing.setVisibleTransmittance(0.70)).to be true - # Unaltered template OpenStudio model: - # - constructions: NO - # - setpoints : NO - # - HVAC : NO - file = File.join(__dir__, "files/osms/in/resto1.osm") - path = OpenStudio::Path.new(file) - model = translator.loadModel(path) - expect(model).to_not be_empty - model = model.get - attic = model.getSpaceByName("Attic") - expect(attic).to_not be_empty - attic = attic.get + layers = OpenStudio::Model::MaterialVector.new + layers << glazing + expect(fen.setLayers(layers)).to be true + expect(fen.layers.size).to eq(1) + expect(fen.layers[0].handle.to_s).to eq(glazing.handle.to_s) + expect(fen.uFactor).to_not be_empty + expect(fen.uFactor.get).to be_within(0.1).of(6.0) - expect(model.getConstructions).to be_empty - heated = TBD.heatingTemperatureSetpoints?(model) - cooled = TBD.coolingTemperatureSetpoints?(model) - expect(heated).to be false - expect(cooled).to be false + # Tube walls. + construction = OpenStudio::Model::Construction.new(model) + construction.setName("tube_construction") + expect(construction.nameString).to eq("tube_construction") + expect(construction.layers).to be_empty - 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) - io = json[:io ] - surfaces = json[:surfaces] - expect(TBD.error?).to be true - expect(TBD.logs).to_not be_empty - expect(surfaces).to be_a(Hash) - expect(surfaces.size).to eq(18) - expect(io).to be_a(Hash) - expect(io).to_not have_key(:edges) + interior = OpenStudio::Model::StandardOpaqueMaterial.new(model) + interior.setName("tube_wall") + expect(interior.nameString).to eq("tube_wall") + expect(interior.setRoughness("MediumRough")).to be true + expect(interior.setThickness(0.0126)).to be true + expect(interior.setConductivity(0.16)).to be true + expect(interior.setDensity(784.9)).to be true + expect(interior.setSpecificHeat(830)).to be true + expect(interior.setThermalAbsorptance(0.9)).to be true + expect(interior.setSolarAbsorptance(0.9)).to be true + expect(interior.setVisibleAbsorptance(0.9)).to be true - TBD.logs.each do |log| - expect(log[:message]).to include("missing").or include("layer?") - end + layers = OpenStudio::Model::MaterialVector.new + layers << interior + expect(construction.setLayers(layers)).to be true + expect(construction.layers.size).to eq(1) + expect(construction.layers[0].handle.to_s).to eq(interior.handle.to_s) - # As the model doesn't hold any constructions, TBD skips over any - # derating steps. Yet despite the OpenStudio model not holding ANY valid - # heating or cooling setpoints, ALL spaces are considered CONDITIONED. - surfaces.values.each do |surface| - expect(surface).to be_a(Hash) - expect(surface).to have_key(:space) - expect(surface).to have_key(:stype) # spacetype - expect(surface).to have_key(:conditioned) - expect(surface).to have_key(:deratable) - expect(surface).to_not have_key(:construction) - expect(surface[:conditioned]).to be true # even attic - expect(surface[:deratable ]).to be false # no constructions! - end + # Host spaces & surfaces. + sp1 = "Core_ZN" + sp2 = "Attic" + z = "Attic ZN" + s1 = "Core_ZN_ceiling" # sp1 surface hosting new TDD diffuser + s2 = "Attic_floor_core" # attic surface, above sp1 + s3 = "Attic_roof_north" # attic surface hosting new TDD dome - # OSut correctly report spaces here as UNCONDITIONED. Tagging ALL spaces - # instead as CONDITIONED in such (rare) cases is unique to TBD. - id = "attic-floor-dinning" - expect(surfaces).to have_key(id) + # Fetch host spaces & surfaces. + core = model.getSpaceByName(sp1) + expect(core).to_not be_empty + core = core.get - attic = surfaces[id][:space] - heat = TBD.setpoints(attic)[:heating] - cool = TBD.setpoints(attic)[:cooling] - expect(TBD.unconditioned?(attic)).to be true - expect(heat).to be_nil - expect(cool).to be_nil - expect(attic.partofTotalFloorArea).to be false - expect(TBD.plenum?(attic)).to be false + attic = model.getSpaceByName(sp2) + expect(attic).to_not be_empty + attic = attic.get + zone = attic.thermalZone + expect(zone).to_not be_empty + zone = zone.get + expect(zone.nameString).to eq(z) - # - ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- - # - # A more developed 'FullServiceRestaurant' (midway BTAP generation): - # - constructions: YES - # - setpoints : YES - # - HVAC : NO - TBD.clean! + ceiling = model.getSurfaceByName(s1) + expect(ceiling).to_not be_empty + ceiling = ceiling.get - file = File.join(__dir__, "files/osms/in/resto2.osm") - path = OpenStudio::Path.new(file) - model = translator.loadModel(path) - expect(model).to_not be_empty - model = model.get + sp = ceiling.space + expect(sp).to_not be_empty + sp = sp.get + expect(sp).to eq(core) - # BTAP-set (interior) ceiling constructions (i.e. attic/plenum floors) - # are characteristic of occupied floors (e.g. carpet over 4" concrete - # slab). Clone/assign insulated roof construction to plenum/attic floors. - set = model.getBuilding.defaultConstructionSet - expect(set).to_not be_empty - set = set.get + floor = model.getSurfaceByName(s2) + expect(floor).to_not be_empty + floor = floor.get - interiors = set.defaultInteriorSurfaceConstructions - exteriors = set.defaultExteriorSurfaceConstructions - expect(interiors).to_not be_empty - expect(exteriors).to_not be_empty - interiors = interiors.get - exteriors = exteriors.get - roofs = exteriors.roofCeilingConstruction - expect(roofs).to_not be_empty - roofs = roofs.get - insulated = roofs.clone(model).to_LayeredConstruction - expect(insulated).to_not be_empty - insulated = insulated.get - insulated.setName("Insulated Attic Floors") - expect(interiors.setRoofCeilingConstruction(insulated)).to be true + sp = floor.space + expect(sp).to_not be_empty + sp = sp.get + expect(sp).to eq(attic) - # Validate re-assignment via individual attic floor surfaces. - construction = nil - ceilings = [] + adj = ceiling.adjacentSurface + expect(adj).to_not be_empty + adj = adj.get + expect(adj).to eq(floor) - model.getSurfaces.each do |s| - next unless s.surfaceType == "RoofCeiling" - next unless s.outsideBoundaryCondition == "Surface" + adj = floor.adjacentSurface + expect(adj).to_not be_empty + adj = adj.get + expect(adj).to eq(ceiling) - ceilings << s.nameString - c = s.construction - expect(c).to_not be_empty - c = c.get.to_LayeredConstruction - expect(c).to_not be_empty - c = c.get + roof = model.getSurfaceByName(s3) + expect(roof).to_not be_empty + roof = roof.get - 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) + sp = roof.space + expect(sp).to_not be_empty + sp = sp.get + expect(sp).to eq(attic) - construction = c if construction.nil? - expect(c).to eq(construction) - end + # Setting heights & Z-axis coordinates. + ceiling_Z = 3.05 + roof_Z = 5.51 + length = roof_Z - ceiling_Z + totalLength = length + 1.0 + dome_Z = ceiling_Z + totalLength - expect(construction ).to eq(insulated) - expect(construction.getNetArea ).to be_within(TOL).of(511.15) - expect(ceilings.size ).to eq(2) - expect(construction.layers.size).to eq(2) - expect(construction.nameString ).to eq("Insulated Attic Floors") - expect(model.getConstructions).to_not be_empty - heated = TBD.heatingTemperatureSetpoints?(model) - cooled = TBD.coolingTemperatureSetpoints?(model) - expect(heated).to be true - expect(cooled).to be true + # A new, 1mx1m diffuser subsurface in Core ceiling. + os_v = OpenStudio::Point3dVector.new + os_v << OpenStudio::Point3d.new( 14.345, 10.845, ceiling_Z) + os_v << OpenStudio::Point3d.new( 14.345, 11.845, ceiling_Z) + os_v << OpenStudio::Point3d.new( 13.345, 11.845, ceiling_Z) + os_v << OpenStudio::Point3d.new( 13.345, 10.845, ceiling_Z) + diffuser = OpenStudio::Model::SubSurface.new(os_v, model) + diffuser.setName("diffuser") + expect(diffuser.setConstruction(fen)).to be true + expect(diffuser.setSubSurfaceType("TubularDaylightDiffuser")).to be true + expect(diffuser.setSurface(ceiling)).to be true + expect(diffuser.uFactor).to_not be_empty + expect(diffuser.uFactor.get).to be_within(0.1).of(6.0) - attic = model.getSpaceByName("attic") - expect(attic).to_not be_empty - attic = attic.get + # A new, 1mx1m dome subsurface above Attic roof. + os_v = OpenStudio::Point3dVector.new + os_v << OpenStudio::Point3d.new( 14.345, 10.845, dome_Z) + os_v << OpenStudio::Point3d.new( 14.345, 11.845, dome_Z) + os_v << OpenStudio::Point3d.new( 13.345, 11.845, dome_Z) + os_v << OpenStudio::Point3d.new( 13.345, 10.845, dome_Z) + dome = OpenStudio::Model::SubSurface.new(os_v, model) + dome.setName("dome") + expect(dome.setConstruction(fen)).to be true + expect(dome.setSubSurfaceType("TubularDaylightDome")).to be true + expect(dome.setSurface(roof)).to be true + expect(dome.uFactor).to_not be_empty + expect(dome.uFactor.get).to be_within(0.1).of(6.0) - expect(attic.partofTotalFloorArea).to be false - heat = TBD.setpoints(attic)[:heating] - cool = TBD.setpoints(attic)[:cooling] - expect(heat).to be_nil - expect(cool).to be_nil + expect(ceiling.tilt).to be_within(TOL).of(diffuser.tilt) + expect(dome.tilt).to be_within(TOL).of(0.0) + expect(roof.tilt).to be_within(TOL).of(0.32) - expect(TBD.plenum?(attic)).to be false - expect(attic.partofTotalFloorArea).to be false - expect(attic.thermalZone).to_not be_empty - zone = attic.thermalZone.get - expect(zone.isPlenum).to be false + rsi = 0.28 + diameter = Math.sqrt(dome.grossArea/Math::PI) * 2 - tstat = zone.thermostat - expect(tstat).to_not be_empty - tstat = tstat.get - expect(tstat.to_ThermostatSetpointDualSetpoint).to_not be_empty - tstat = tstat.to_ThermostatSetpointDualSetpoint.get - expect(tstat.getHeatingSchedule).to be_empty - expect(tstat.getCoolingSchedule).to be_empty + tdd = OpenStudio::Model::DaylightingDeviceTubular.new( + dome, diffuser, construction, diameter, totalLength, rsi) - heat = TBD.maxHeatScheduledSetpoint(zone) - cool = TBD.minCoolScheduledSetpoint(zone) - expect(heat).to_not be_nil - expect(cool).to_not be_nil - expect(heat).to be_a(Hash) - expect(cool).to be_a(Hash) - expect(heat).to have_key(:spt) - expect(cool).to have_key(:spt) - expect(heat).to have_key(:dual) - expect(cool).to have_key(:dual) - expect(heat[:spt]).to be_nil - expect(cool[:spt]).to be_nil - expect(heat[:dual]).to be false - expect(cool[:dual]).to be false + expect(tdd.addTransitionZone(zone, length)).to be true + cl = OpenStudio::Model::TransitionZoneVector + expect(tdd.transitionZones.class).to eq(cl) + expect(tdd.numberofTransitionZones).to eq(1) + expect(tdd.totalLength).to be_within(0.001).of(totalLength) - # The unoccupied space does not reference valid heating and/or cooling - # temperature setpoint objects, and is therefore considered - # UNCONDITIONED. Save for next iteration. - file = File.join(__dir__, "files/osms/out/resto2a.osm") - model.save(file, true) + expect(tdd.subSurfaceDome).to eq(dome) + expect(tdd.subSurfaceDiffuser).to eq(diffuser) + c = tdd.construction + expect(c.to_LayeredConstruction).to_not be_empty + c = c.to_LayeredConstruction.get + expect(c.nameString).to eq(construction.nameString) + expect(tdd.diameter).to be_within(0.001).of(diameter) + expect(tdd.effectiveThermalResistance).to be_within(TOL).of(rsi) - argh = {} - argh[:option ] = "efficient (BETBG)" - argh[:uprate_roofs] = true - argh[:roof_option ] = "ALL roof constructions" - argh[:roof_ut ] = 0.138 # NECB CZ7 2017 (RSi 7.25 / R41) + pth = File.join(__dir__, "files/osms/out/tdd_smalloffice_test.osm") + model.save(pth, true) + + # Testing if TBD recognizes the TDD as a "skylight" (for derating & UA'). + argh = { option: "poor (BETBG)" } json = TBD.process(model, argh) expect(json).to be_a(Hash) @@ -10135,461 +10049,527 @@ expect(TBD.status).to be_zero expect(TBD.logs).to be_empty expect(surfaces).to be_a(Hash) - expect(surfaces.size).to eq(18) + expect(surfaces.size).to eq(43) expect(io).to be_a(Hash) expect(io).to have_key(:edges) - expect(io[:edges].size).to eq(31) - - expect(argh).to_not have_key(:wall_uo) - expect(argh).to have_key(:roof_uo) - expect(argh[:roof_uo]).to be_within(TOL).of(0.119) + expect(io[:edges].size).to eq(109) - # Validate ceiling surfaces (both insulated & uninsulated). - ua = 0.0 - a = 0.0 + # Both diffuser and parent ceiling are stored as TBD 'surfaces'. + expect(surfaces).to have_key(s1) + surface = surfaces[s1] + expect(surface).to have_key(:skylights) + expect(surface[:skylights]).to have_key("diffuser") - surfaces.each do |nom, surface| - expect(surface).to be_a(Hash) + skylight = surface[:skylights]["diffuser"] + expect(skylight).to have_key(:u) + expect(skylight[:u]).to be_a(Numeric) + expect(skylight[:u]).to be_within(TOL).of(1/rsi) - 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) - expect(surface).to have_key(:type) - next if surface[:ground] - next unless surface[:type ] == :ceiling + # ... yet TBD only derates constructions of opaque surfaces in CONDITIONED + # spaces IF (i) facing outdoors or (ii) facing UNCONDITIONED spaces like + # attics (see psi.rb). Here, the ceiling is tagged by TBD as a deratable + # surface, and hence the diffuser edges are logged in TBD's 'edges'. + expect(surface).to have_key(:ratio) + expect(surface).to have_key(:heatloss) + expect(surface[:heatloss]).to be_within(TOL).of(2.00) # 4x 0.500 W/K - # Sloped attic roof surfaces ignored by TBD. - id = surface[:construction].nameString - expect(nom).to include("-roof" ) unless surface[:deratable] - expect(id ).to include("BTAP-Ext-") unless surface[:deratable] - expect(surface[:conditioned] ).to be false unless surface[:deratable] - next unless surface[:deratable] - next unless surface.key?(:heatloss) + # Only edges of the diffuser (linked to the ceiling) are stored. + io[:edges].each do |edge| + expect(edge).to be_a(Hash) + expect(edge).to have_key(:surfaces) + expect(edge[:surfaces]).to be_a(Array) - # Leaves only insulated attic ceilings. - expect(id).to eq("Insulated Attic Floors") # original construction - s = model.getSurfaceByName(nom) - expect(s).to_not be_empty - s = s.get - c = s.construction - expect(c).to_not be_empty - c = c.get.to_LayeredConstruction - expect(c).to_not be_empty - c = c.get + edge[:surfaces].each do |id| + next unless ["dome", "diffuser"].include?(id) - expect(c.nameString).to include("c tbd") # TBD-derated - a += surface[:net] - ua += 1 / TBD.rsi(c, surface[:filmRSI]) * surface[:net] + expect(id).to eq("diffuser") + end end - expect(ua / a).to be_within(TOL).of(argh[:roof_ut]) + expect(surfaces).to have_key(s3) + surface = surfaces[s3] + expect(surface).to have_key(:skylights) + expect(surface[:skylights]).to have_key("dome") + skylight = surface[:skylights]["dome"] + expect(skylight).to have_key(:u) + expect(skylight[:u]).to be_a(Numeric) + expect(skylight[:u]).to be_within(TOL).of(1/rsi) + expect(surface).to_not have_key(:heatloss) + expect(surface).to_not have_key(:ratio) - # - ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- - # - # Altered model from previous iteration, yet no uprating this round. - # - constructions: YES - # - setpoints : YES - # - HVAC : NO - TBD.clean! + expect(io[:edges].size).to eq(109) # 4x extra edges for diffuser only - file = File.join(__dir__, "files/osms/out/resto2a.osm") - path = OpenStudio::Path.new(file) - model = translator.loadModel(path) - expect(model).to_not be_empty - model = model.get - heated = TBD.heatingTemperatureSetpoints?(model) - cooled = TBD.coolingTemperatureSetpoints?(model) - expect(model.getConstructions).to_not be_empty - expect(heated).to be true - expect(cooled).to be true + out = JSON.pretty_generate(io) + outP = File.join(__dir__, "../json/tbd_smalloffice1.out.json") - # In this iteration, ensure the unoccupied space is considered as an - # INDIRECTLYCONDITIONED plenum (instead of an UNCONDITIONED attic), by - # temporarily adding a heating dual setpoint schedule object to its zone - # thermostat (yet without valid scheduled temperatures). - attic = model.getSpaceByName("attic") - expect(attic).to_not be_empty - attic = attic.get - expect(attic.partofTotalFloorArea).to be false - expect(attic.thermalZone).to_not be_empty - zone = attic.thermalZone.get - expect(zone.isPlenum).to be false - tstat = zone.thermostat - expect(tstat).to_not be_empty - tstat = tstat.get + File.open(outP, "w") { |outP| outP.puts out } - expect(tstat.to_ThermostatSetpointDualSetpoint).to_not be_empty - tstat = tstat.to_ThermostatSetpointDualSetpoint.get + # Re-use the exported file as input for another test. + model2 = translator.loadModel(pth) + expect(model2).to_not be_empty + model2 = model2.get + jpath = "../json/tbd_smalloffice1.out.json" - # Before the addition. - expect(tstat.getHeatingSchedule).to be_empty - expect(tstat.getCoolingSchedule).to be_empty + argh[:schema_path] = File.join(__dir__, "../tbd.schema.json") + argh[:io_path ] = File.join(__dir__, jpath) - heat = TBD.maxHeatScheduledSetpoint(zone) - cool = TBD.minCoolScheduledSetpoint(zone) - stpts = TBD.setpoints(attic) + json2 = TBD.process(model2, argh) + expect(json2).to be_a(Hash) + expect(json2).to have_key(:io) + expect(json2).to have_key(:surfaces) + io2 = json2[:io ] + surfaces = json2[:surfaces] + expect(TBD.status).to be_zero + expect(TBD.logs).to be_empty + expect(surfaces).to be_a(Hash) + expect(surfaces.size).to eq(43) + expect(io).to be_a(Hash) + expect(io).to have_key(:edges) + expect(io[:edges].size).to eq(109) - expect(heat).to_not be_nil - expect(cool).to_not be_nil - expect(heat).to be_a(Hash) - expect(cool).to be_a(Hash) - expect(heat).to have_key(:spt) - expect(cool).to have_key(:spt) - expect(heat).to have_key(:dual) - expect(cool).to have_key(:dual) - expect(heat[:spt]).to be_nil - expect(cool[:spt]).to be_nil - expect(heat[:dual]).to be false - expect(cool[:dual]).to be false + # Now mimic (again) the export functionality of the measure. Both output + # files should be the same. + out2 = JSON.pretty_generate(io2) + outP2 = File.join(__dir__, "../json/tbd_smalloffice2.out.json") + File.open(outP2, "w") { |outP2| outP2.puts out2 } + expect(FileUtils).to be_identical(outP, outP2) + end + end - expect(stpts[:heating]).to be_nil - expect(stpts[:cooling]).to be_nil - expect(TBD.unconditioned?(attic)).to be true - expect(TBD.plenum?(attic)).to be false + it "can handle air gaps as materials" do + translator = OpenStudio::OSVersion::VersionTranslator.new + TBD.clean! - # Add a dual setpoint temperature schedule. - identifier = "TEMPORARY attic setpoint schedule" + 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 + id = "Bulk Storage Rear Wall" - sched = OpenStudio::Model::ScheduleCompact.new(model) - sched.setName(identifier) - expect(sched.constantValue).to be_empty - expect(tstat.setHeatingSetpointTemperatureSchedule(sched)).to be true + s = model.getSurfaceByName(id) + expect(s).to_not be_empty + s = s.get + expect(s.nameString).to eq(id) + expect(s.surfaceType).to eq("Wall") + expect(s.isConstructionDefaulted).to be true + c = s.construction.get.to_LayeredConstruction + expect(c).to_not be_empty + c = c.get + expect(c.numLayers).to eq(3) - # After the addition. - expect(tstat.getHeatingSchedule).to_not be_empty - expect(tstat.getCoolingSchedule).to be_empty - heat = TBD.maxHeatScheduledSetpoint(zone) - stpts = TBD.setpoints(attic) + gap = OpenStudio::Model::AirGap.new(model) + expect(gap.handle.to_s).to_not be_empty + expect(gap.nameString).to_not be_empty + expect(gap.nameString).to eq("Material Air Gap 1") + gap.setName("#{id} air gap") + expect(gap.nameString).to eq("#{id} air gap") + expect(gap.setThermalResistance(0.180)).to be true + expect(gap.thermalResistance).to be_within(TOL).of(0.180) + expect(c.insertLayer(1, gap)).to be true + expect(c.numLayers).to eq(4) - expect(heat).to_not be_nil - expect(heat).to be_a(Hash) - expect(heat).to have_key(:spt) - expect(heat).to have_key(:dual) - expect(heat[:spt ]).to be_nil - expect(heat[:dual]).to be true + pth = File.join(__dir__, "files/osms/out/warehouse_airgap.osm") + model.save(pth, true) - expect(stpts[:heating]).to be_within(TOL).of(21.0) - expect(stpts[:cooling]).to be_within(TOL).of(24.0) + argh = { option: "poor (BETBG)" } + + TBD.process(model, argh) + expect(TBD.status).to be_zero + end + + it "can uprate (ALL roof) constructions" do + translator = OpenStudio::OSVersion::VersionTranslator.new + TBD.clean! + + 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 + rf1 = "Typical Insulated Metal Building Roof R-10.31 1" + rf2 = "Typical Insulated Metal Building Roof R-18.18" + + model.getSurfaces.each do |s| + next unless s.surfaceType.downcase == "roofceiling" + next unless s.outsideBoundaryCondition.downcase == "outdoors" + next if s.construction.empty? + next if s.construction.get.to_LayeredConstruction.empty? - expect(TBD.unconditioned?(attic)).to be false - expect(TBD.plenum?(attic)).to be true # works ... + lc = s.construction.get.to_LayeredConstruction.get + id = lc.nameString + flm = s.filmResistance + expect([rf1, rf2]).to include(id) + expect(flm.round(4)).to eq(0.1360) + expect(TBD.rsi(lc, flm).round(3)).to eq(1.814) if id == rf1 # R10 + expect(TBD.rsi(lc, flm).round(3)).to eq(3.201) if id == rf2 # R18 + end - 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) - io = json[:io ] - surfaces = json[:surfaces] - expect(TBD.error?).to be true - expect(TBD.logs.size).to eq(18) - expect(surfaces).to be_a(Hash) - expect(surfaces.size).to eq(18) - expect(io).to be_a(Hash) - expect(io).to have_key(:edges) - expect(io[:edges].size).to eq(35) + # One challenge of the uprating approach concerns OpenStudio-reported + # surface film resistances, which factor-in the slope of the surface and + # surface emittances. As the uprate approach relies on user-defined Ut + # factors (inputs, as targets to meet), it also considers surface film + # resistances. In the schematic cross-section below, let's postulate that + # each slope has a unique pitch: 50deg (s1), 0deg (s2), & 60dge (s3). All + # three surfaces reference the same construction. + # + # s2 + # _____ + # / \ + # s1 / \ s3 + # / \ + # + # For highly-reflective interior finishes (think of Bruce Lee in Enter the + # Dragon), the difference here in reported RSi could reach 0.1 m2.K/W or + # R0.6. That's a 1% to 3% difference for a well-insulated construction. This + # may seem significant at first, but the impact on energy simulation results + # should barely be noticeable for well-insulated constructions. Yet such + # discrepancies can become an irritant when processing an OpenStudio model + # for code compliance purposes. This is more challenging when some envelope + # surfaces are INTERZONE (e.g. insulated attic floor). + # + # When uprating clear-field (Uo) calculations, prior TBD versions ensured + # that the shared layered construction met the minimal code requirements + # for the surface with the lowest surface air film resistance, here s2. + # Surfaces s1 & s3 would slightly overshoot the uprated Uo target. + # + # The v3.6 fix now averages out surface air film resistances, as follows: + # + # area-weighted filmRSI = 1 / ( ∑ ( 1/filmRSIi • AREAi ) / AREAt ) + # + # Relying on an area-weighted average of surface air film resistances, some + # surface will report final (derated) Ut values slightly below target, + # others slightly above. Yet the area-weighted average (UA-based) should + # match the code-required Ut requirement. + # + # The other v3.6 change is maintaining user-assigned constructions (i.e. + # not replacing them with a single, predominant roof or wall construction). + # Each construction is certainly uprated, then derated. Yet the original + # user-defined, non-insulating layers are maintained as is. - # The incomplete (temporary) schedule triggers a non-FATAL TBD error. - TBD.logs.each do |log| - expect(log[:message]).to include("Empty '") - expect(log[:message]).to include("::scheduleCompactMinMax)") - end + argh = {} + argh[:roof_option ] = "ALL roof constructions" + argh[:option ] = "poor (BETBG)" + argh[:uprate_roofs] = true + argh[:roof_ut ] = 0.138 # NECB 2017 (RSi 7.25 / R41) - surfaces.each do |nom, surface| - expect(surface).to be_a(Hash) + 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.status).to be_zero + expect(TBD.logs).to be_empty + expect(surfaces).to be_a(Hash) + expect(surfaces.size).to eq(23) + expect(io).to be_a(Hash) + expect(io).to have_key(:edges) + expect(io[:edges].size).to eq(300) - expect(surface).to have_key(:conditioned) - expect(surface).to have_key(:deratable) - expect(surface).to have_key(:construction) - expect(surface).to have_key(:ground) - expect(surface).to have_key(:type) - next unless surface[:type] == :ceiling + bulk = "Bulk Storage Roof" + fine = "Fine Storage Roof" - # Sloped attic roof surfaces no longer ignored by TBD. - id = surface[:construction].nameString - expect(nom).to include("-roof" ) if surface[:deratable] - expect(nom).to include("_Ceiling" ) unless surface[:deratable] - expect(id ).to include("BTAP-Ext-") if surface[:deratable] + # OpenStudio objects. + bulk_roof = model.getSurfaceByName(bulk) + fine_roof = model.getSurfaceByName(fine) + expect(bulk_roof).to_not be_empty + expect(fine_roof).to_not be_empty + bulk_roof = bulk_roof.get + fine_roof = fine_roof.get - expect(surface[:conditioned]).to be true - next unless surface[:deratable] - next unless surface.key?(:heatloss) + bulk_construction = bulk_roof.construction + fine_construction = fine_roof.construction + expect(bulk_construction).to_not be_empty + expect(fine_construction).to_not be_empty - # Leaves only insulated attic ceilings. - expect(id).to eq("BTAP-Ext-Roof-Metal:U-0.162") # original construction - s = model.getSurfaceByName(nom) - expect(s).to_not be_empty - s = s.get - 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") # TBD-derated - end + bulk_construction = bulk_construction.get.to_LayeredConstruction + fine_construction = fine_construction.get.to_LayeredConstruction + expect(bulk_construction).to_not be_empty + expect(fine_construction).to_not be_empty - # Once done, ensure temporary schedule is dissociated from the thermostat - # and deleted from the model. - tstat.resetHeatingSetpointTemperatureSchedule - expect(tstat.getHeatingSchedule).to be_empty + bulk_construction = bulk_construction.get + fine_construction = fine_construction.get + expect(bulk_construction.nameString).to eq("Bulk Storage Roof c tbd") + expect(fine_construction.nameString).to eq("Fine Storage Roof c tbd") + expect(bulk_construction.layers.size).to eq(2) + expect(fine_construction.layers.size).to eq(2) - sched2 = model.getScheduleByName(identifier) - expect(sched2).to_not be_empty - sched2.get.remove - sched2 = model.getScheduleByName(identifier) - expect(sched2).to be_empty + bulk_insulation = bulk_construction.layers.at(1).to_MasslessOpaqueMaterial + fine_insulation = fine_construction.layers.at(1).to_MasslessOpaqueMaterial + expect(bulk_insulation).to_not be_empty + expect(fine_insulation).to_not be_empty - heat = TBD.maxHeatScheduledSetpoint(zone) - stpts = TBD.setpoints(attic) + bulk_insulation = bulk_insulation.get + fine_insulation = fine_insulation.get + bulk_insulation_r = bulk_insulation.thermalResistance + fine_insulation_r = fine_insulation.thermalResistance + expect(bulk_insulation_r.round(3)).to eq(7.110) # once derated + expect(fine_insulation_r.round(3)).to eq(7.110) # once derated - expect(heat).to be_a(Hash) - expect(heat).to have_key(:spt ) - expect(heat).to have_key(:dual) - expect(heat[:spt ]).to be_nil - expect(heat[:dual]).to be false + # Both constructions are uprated, then derated to meet the same NECB target. + rsi_bulk = TBD.rsi(bulk_construction, bulk_roof.filmResistance) + rsi_fine = TBD.rsi(fine_construction, fine_roof.filmResistance) + usi_fine = 1 / rsi_fine + expect(rsi_bulk.round(3)).to eq(rsi_fine.round(3)) + expect(usi_fine.round(3)).to eq(argh[:roof_ut]) - expect(stpts[:heating]).to be_nil - expect(stpts[:cooling]).to be_nil - expect(TBD.plenum?(attic)).to be false # as before ... + # TBD objects. + expect(surfaces).to have_key(bulk) + expect(surfaces).to have_key(fine) + expect(surfaces[bulk]).to have_key(:heatloss) + expect(surfaces[fine]).to have_key(:heatloss) + expect(surfaces[bulk]).to have_key(:net) + expect(surfaces[fine]).to have_key(:net) + expect(surfaces[bulk][:heatloss].round(2)).to eq(161.02) + expect(surfaces[fine][:heatloss].round(2)).to eq( 87.16) + expect(surfaces[bulk][:net ].round(2)).to eq(3157.28) + expect(surfaces[fine][:net ].round(2)).to eq(1372.60) - # -- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- -- # - TBD.clean! + heatloss = surfaces[bulk][:heatloss] + surfaces[fine][:heatloss] + area = surfaces[bulk][:net ] + surfaces[fine][:net ] - # Same, altered model from previous iteration (yet to uprate): - # - constructions: YES - # - setpoints : YES - # - HVAC : NO - file = File.join(__dir__, "files/osms/out/resto2a.osm") - path = OpenStudio::Path.new(file) - model = translator.loadModel(path) - expect(model).to_not be_empty - model = model.get - expect(model.getConstructions).to_not be_empty + expect(heatloss.round(2)).to eq( 248.19) + expect( area.round(2)).to eq(4529.88) - heated = TBD.heatingTemperatureSetpoints?(model) - cooled = TBD.coolingTemperatureSetpoints?(model) - expect(heated).to be true - expect(cooled).to be true + # The TBD data model tracks the initially-uprated constructions. + expect(surfaces[bulk]).to have_key(:construction) # not yet derated + expect(surfaces[fine]).to have_key(:construction) - # Get geometry data for testing (4x exterior roofs, same construction). - id = "BTAP-Ext-Roof-Metal:U-0.162" - construction = nil - roofs = [] + expect(surfaces[bulk][:construction].nameString).to eq(rf1) + expect(surfaces[fine][:construction].nameString).to eq(rf2) - model.getSurfaces.each do |s| - next unless s.surfaceType == "RoofCeiling" - next unless s.outsideBoundaryCondition == "Outdoors" + # The initially-uprated roof construction is maintained in the model. + uprated = model.getConstructionByName(rf1) + expect(uprated).to_not be_empty + uprated = uprated.get + expect(uprated.to_LayeredConstruction).to_not be_empty + uprated = uprated.to_LayeredConstruction.get - roofs << s.nameString - c = s.construction - expect(c).to_not be_empty - c = c.get.to_LayeredConstruction - expect(c).to_not be_empty - c = c.get + expect(uprated.is_a?(OpenStudio::Model::LayeredConstruction)).to be true + expect(uprated.layers.size).to eq(2) - construction = c if construction.nil? - expect(c).to eq(construction) - end + uprated.layers.each do |layer| + next unless layer.nameString.include?(" uprated") - expect(construction.getNetArea ).to be_within(TOL).of(569.51) - expect(roofs.size ).to eq( 4) - expect(construction.nameString ).to eq(id) - expect(construction.layers.size).to eq( 2) + expect(layer.to_MasslessOpaqueMaterial).to_not be_empty + layer = layer.to_MasslessOpaqueMaterial.get + expect(layer.thermalResistance.round(2)).to eq(11.16) # m2.K/W (R63) + end - insulation = construction.layers[1].to_MasslessOpaqueMaterial - expect(insulation).to_not be_empty - insulation = insulation.get - original_r = insulation.thermalResistance - expect(original_r).to be_within(TOL).of(6.17) + file = File.join(__dir__, "files/osms/out/up_warehouse.osm") + model.save(file, true) + end - # Attic spacetype as plenum, an alternative to the inactive thermostat. - attic = model.getSpaceByName("attic") - expect(attic).to_not be_empty - attic = attic.get - sptype = attic.spaceType - expect(sptype).to_not be_empty - sptype = sptype.get - sptype.setName("Attic as Plenum") + it "can uprate (ALL wall) constructions - poor (BETBG)" do + translator = OpenStudio::OSVersion::VersionTranslator.new + TBD.clean! - stpts = TBD.setpoints(attic) - expect(stpts[:heating]).to be_within(TOL).of(21.0) - expect(TBD.unconditioned?(attic)).to be false - expect(TBD.plenum?(attic)).to be true # works ... + 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 - argh = {} - argh[:option ] = "efficient (BETBG)" - argh[:uprate_walls] = true - argh[:uprate_roofs] = true - argh[:wall_option ] = "ALL wall constructions" - argh[:roof_option ] = "ALL roof constructions" - argh[:wall_ut ] = 0.210 # NECB CZ7 2017 (RSi 4.76 / R27) - argh[:roof_ut ] = 0.138 # NECB CZ7 2017 (RSi 7.25 / R41) + w1 = "Typical Insulated Metal Building Wall R-8.85 1" + w2 = "Typical Insulated Metal Building Wall R-11.9" + w3 = "Typical Insulated Metal Building Wall R-11.9 1" - 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.status).to be_zero + model.getSurfaces.each do |s| + next unless s.surfaceType.downcase == "wall" + next unless s.outsideBoundaryCondition.downcase == "outdoors" + next if s.construction.empty? + next if s.construction.get.to_LayeredConstruction.empty? - expect(argh).to have_key(:wall_uo) - expect(argh).to have_key(:roof_uo) - expect(argh[:roof_uo]).to be_within(TOL).of(0.120) # RSi 8.3 ( R47) - expect(argh[:wall_uo]).to be_within(TOL).of(0.012) # RSi 83.3 (R473) + lc = s.construction.get.to_LayeredConstruction.get + id = lc.nameString + flm = s.filmResistance + expect([w1, w2, w3]).to include(id) + expect(flm.round(4)).to eq(0.1496) + expect(TBD.rsi(lc, flm).round(3)).to eq(1.558) if id == w1 # R08.8 + expect(TBD.rsi(lc, flm).round(3)).to eq(2.096) if id == w2 # R11.9 + expect(TBD.rsi(lc, flm).round(3)).to eq(2.096) if id == w3 # R11.9 + end - # Validate ceiling surfaces (both insulated & uninsulated). - ua = 0.0 - a = 0 - area = 0 + # Deeper dive into w1 (more prevalent). + targeted = model.getConstructionByName(w1) + expect(targeted).to_not be_empty + targeted = targeted.get + expect(targeted.to_LayeredConstruction).to_not be_empty + targeted = targeted.to_LayeredConstruction.get + expect(targeted.is_a?(OpenStudio::Model::LayeredConstruction)).to be true + expect(targeted.layers.size).to eq(3) - 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) - expect(surface).to have_key(:type) - next if surface[:ground] - next unless surface[:type ] == :ceiling + targeted.layers.each do |layer| + next unless layer.nameString == "Typical Insulation R-7.55 1" + expect(layer.to_MasslessOpaqueMaterial).to_not be_empty + layer = layer.to_MasslessOpaqueMaterial.get + expect(layer.thermalResistance).to be_within(TOL).of(1.33) # m2.K/W (R7.6) + end - # Sloped plenum roof surfaces no longer ignored by TBD. - id = surface[:construction].nameString - expect(nom).to include("-roof" ) if surface[:deratable] - expect(id ).to include("BTAP-Ext-") if surface[:deratable] + # Set w1 (a wall construction) as the 'Bulk Storage Roof' construction. This + # triggers a TBD warning when uprating: a safeguard limiting uprated + # constructions to single surface types (e.g. can't be referenced by both + # roof AND wall surfaces). + bulk = "Bulk Storage Roof" + bulk_roof = model.getSurfaceByName(bulk) + expect(bulk_roof).to_not be_empty + bulk_roof = bulk_roof.get + expect(bulk_roof.isConstructionDefaulted).to be true - expect(surface[:conditioned]).to be true if surface[:deratable] - expect(nom).to include("_Ceiling") unless surface[:deratable] - expect(surface[:conditioned]).to be true unless surface[:deratable] + bulk_construction = bulk_roof.construction + expect(bulk_construction).to_not be_empty + bulk_construction = bulk_construction.get.to_LayeredConstruction + expect(bulk_construction).to_not be_empty + bulk_construction = bulk_construction.get + expect(bulk_construction.numLayers).to eq(2) + expect(bulk_roof.setConstruction(targeted)).to be true + expect(bulk_roof.isConstructionDefaulted).to be false - next unless surface[:deratable] - next unless surface.key?(:heatloss) + argh = {} + argh[:wall_option ] = "ALL wall constructions" + argh[:option ] = "poor (BETBG)" + argh[:uprate_walls] = true + argh[:wall_ut ] = 0.210 # (R27), NECB 2017 - # Leaves only insulated plenum roof surfaces. - expect(id).to eq("BTAP-Ext-Roof-Metal:U-0.162") # original construction - s = model.getSurfaceByName(nom) - expect(s).to_not be_empty - s = s.get - 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") # TBD-derated + 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] - a += surface[:net] - ua += 1 / TBD.rsi(c, surface[:filmRSI]) * surface[:net] - end + # PSI-factors of the "poor (BETBG)" set are too conductive. The total heat + # loss (W/K) from thermal bridging is too great for insulation materials to + # absorb (given TBD/OSut admissible ranges). TBD fails to completely uprate + # walls to meet NECB 2017 Ut requirements. In such cases, TBD logs the + # failure, yet partially uprates non-compliant wall constructions by setting + # the uprated Uo to UMIN. + expect(TBD.warn?).to be true + expect(TBD.logs.size).to eq(3) + expect(TBD.logs[0][:message]).to include("Cloning 'Bulk Storage Roof' ") + expect(TBD.logs[1][:message]).to include("Negative ") + expect(TBD.logs[2][:message]).to include("Unable to completely uprate ") + expect(surfaces).to be_a(Hash) + expect(surfaces.size).to eq(23) + expect(io).to be_a(Hash) + expect(io).to have_key(:edges) + expect(io[:edges].size).to eq(300) - expect(ua / a).to be_within(TOL).of(argh[:roof_ut]) + bulk_roof = model.getSurfaceByName(bulk) + expect(bulk_roof).to_not be_empty + bulk_roof = bulk_roof.get - roofs.each do |roof| - expect(surfaces).to have_key(roof) - expect(surfaces[roof]).to have_key(:deratable) - expect(surfaces[roof]).to have_key(:edges) - expect(surfaces[roof][:deratable]).to be true + bulk_construction = bulk_roof.construction + expect(bulk_construction).to_not be_empty + bulk_construction = bulk_construction.get.to_LayeredConstruction + expect(bulk_construction).to_not be_empty + bulk_construction = bulk_construction.get + expect(bulk_construction.nameString).to eq("#{bulk} c tbd") + expect(bulk_construction.numLayers ).to eq(3) # not 2 - surfaces[roof][:edges].values.each do |edge| - expect(edge).to have_key(:psi) - expect(edge).to have_key(:length) - expect(edge).to have_key(:ratio) - expect(edge).to have_key(:type) - next if edge[:type] == :transition + layer0 = bulk_construction.layers[0] + layer1 = bulk_construction.layers[1] + layer2 = bulk_construction.layers[2] + expect(layer1.nameString).to eq("#{bulk} m tbd") # not uprated - expect(edge[:ratio]).to be_within(TOL).of(0.579) - expect(edge[:psi ]).to be_within(TOL).of(0.200 * edge[:ratio]) - end + uA = 0 + m2 = 0 - loss = 22.61 * 0.200 * 0.579 - expect(surfaces[roof]).to have_key(:heatloss) - expect(surfaces[roof]).to have_key(:net) - expect(surfaces[roof][:heatloss]).to be_within(TOL).of(loss) - area += surfaces[roof][:net] - end + model.getSurfaces.each do |s| + next unless s.surfaceType.downcase == "wall" + next unless s.outsideBoundaryCondition.downcase == "outdoors" - expect(area).to be_within(TOL).of(569.50) + expect(s.construction).to_not be_empty + expect(s.construction.get.to_LayeredConstruction).to_not be_empty + c = s.construction.get.to_LayeredConstruction.get + expect(c.numLayers).to eq(3) + expect(c.layers[0]).to eq(layer0) # same as Bulk Storage Roof + expect(c.layers[1].nameString).to include(" uprated ") + expect(c.layers[1].nameString).to include(" m tbd") + expect(c.layers[2]).to eq(layer2) # same as Bulk Storage Roof + + m2 += s.netArea + uA += s.netArea / TBD.rsi(c, s.filmResistance) end - # --- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- --- # - # Add skylight (+ skylight well) to corrected SEB model. - TBD.clean! - file = File.join(__dir__, "files/osms/out/seb2.osm") - path = OpenStudio::Path.new(file) - model = translator.loadModel(path) - expect(model).to_not be_empty - model = model.get + ut = uA / m2 + expect(ut.round(3)).to eq(0.226) # R21, below the required R27 - entry = model.getSpaceByName("Entry way 1") - office = model.getSpaceByName("Small office 1") - open = model.getSpaceByName("Open area 1") - utility = model.getSpaceByName("Utility 1") - plenum = model.getSpaceByName("Level 0 Ceiling Plenum") - expect(entry).to_not be_empty - expect(office).to_not be_empty - expect(open).to_not be_empty - expect(utility).to_not be_empty - expect(plenum).to_not be_empty - entry = entry.get - office = office.get - open = open.get - utility = utility.get - plenum = plenum.get - expect(plenum.partofTotalFloorArea).to be false - expect(TBD.unconditioned?(plenum)).to be false + # TBD objects. + expect(surfaces).to have_key(bulk) + expect(surfaces[bulk]).to have_key(:heatloss) + expect(surfaces[bulk]).to have_key(:net) - open_roofs = TBD.roofs(open) - expect(open_roofs.size).to eq(1) - open_roof = open_roofs.first - roof_id = open_roof.nameString - expect(roof_id).to eq("Level 0 Open area 1 Ceiling Plenum RoofCeiling") + # By initially inheriting the wall construction, the bulk roof surface is + # slightly less derated (152.40 W/K instead of 161.02 W/K), due to TBD's + # proportionate psi distribution between surface edges. + expect(surfaces[bulk][:heatloss]).to be_within(TOL).of(152.40) + expect(surfaces[bulk][:net]).to be_within(TOL).of(3157.28) + expect(surfaces[bulk]).to have_key(:construction) # not yet derated + nom = surfaces[bulk][:construction].nameString + expect(nom).to include("cloned") - srr = 0.05 - gra = TBD.grossRoofArea(model.getSpaces) - tm2 = srr * gra - rm2 = TBD.addSkyLights(model.getSpaces, {area: tm2}) - puts TBD.logs unless TBD.logs.empty? - expect(TBD.status).to be_zero - expect(rm2.round(2)).to eq(gra.round(2)) + file = File.join(__dir__, "files/osms/out/up2_warehouse.osm") + model.save(file, true) + end - entry_skies = TBD.facets(entry, "Outdoors", "Skylight") - office_skies = TBD.facets(office, "Outdoors", "Skylight") - utility_skies = TBD.facets(utility, "Outdoors", "Skylight") - open_skies = TBD.facets(open, "Outdoors", "Skylight") + it "can uprate (ALL wall) constructions - efficient (BETBG)" do + translator = OpenStudio::OSVersion::VersionTranslator.new + TBD.clean! - expect(entry_skies).to be_empty - expect(office_skies).to be_empty - expect(utility_skies).to be_empty - expect(open_skies.size).to eq(1) - open_sky = open_skies.first - sky_id = open_sky.nameString - expect(sky_id).to eq("0:0:0:Open area 1:0") + 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 - skm2 = open_sky.grossArea - expect((skm2 / rm2).round(2)).to eq(srr) + w1 = "Typical Insulated Metal Building Wall R-8.85 1" + w2 = "Typical Insulated Metal Building Wall R-11.9" + w3 = "Typical Insulated Metal Building Wall R-11.9 1" - # Assign construction to new skylights. - construction = TBD.genConstruction(model, {type: :skylight, uo: 2.8}) - expect(open_sky.setConstruction(construction)).to be true - puts TBD.logs unless TBD.logs.empty? - expect(TBD.status).to be_zero + # Deeper dive into w1 (more prevalent). + targeted = model.getConstructionByName(w1) + expect(targeted).to_not be_empty + targeted = targeted.get + expect(targeted.to_LayeredConstruction).to_not be_empty + targeted = targeted.to_LayeredConstruction.get + expect(targeted.is_a?(OpenStudio::Model::LayeredConstruction)).to be true + expect(targeted.layers.size).to eq(3) - file = File.join(__dir__, "files/osms/out/seb2_sky.osm") - model.save(file, true) + targeted.layers.each do |layer| + next unless layer.nameString == "Typical Insulation R-7.55 1" + expect(layer.to_MasslessOpaqueMaterial).to_not be_empty + layer = layer.to_MasslessOpaqueMaterial.get + expect(layer.thermalResistance).to be_within(TOL).of(1.33) # m2.K/W (R7.6) + end - open_well = open_sky.surface - expect(open_well).to_not be_empty - open_well = open_well.get - expect(open_well.surfaceType.downcase).to eq("roofceiling") - well_id = open_well.nameString - expect(well_id).to eq("0:0:0:Open area 1") + # Set w1 (a wall construction) as the 'Bulk Storage Roof' construction. This + # triggers a TBD warning when uprating: a safeguard limiting uprated + # constructions to single surface type (e.g. can't be referenced by both + # roof AND wall surfaces). + bulk = "Bulk Storage Roof" + bulk_roof = model.getSurfaceByName(bulk) + expect(bulk_roof).to_not be_empty + bulk_roof = bulk_roof.get + expect(bulk_roof.isConstructionDefaulted).to be true - argh = {} - argh[:option ] = "regular (BETBG)" - argh[:schema_path] = File.join(__dir__, "../tbd.schema.json") + bulk_construction = bulk_roof.construction + expect(bulk_construction).to_not be_empty + bulk_construction = bulk_construction.get.to_LayeredConstruction + expect(bulk_construction).to_not be_empty + bulk_construction = bulk_construction.get + expect(bulk_construction.numLayers).to eq(2) + expect(bulk_roof.setConstruction(targeted)).to be true + expect(bulk_roof.isConstructionDefaulted).to be false + + argh = {} + argh[:wall_option ] = "ALL wall constructions" + argh[:option ] = "efficient (BETBG)" # vs preceding test + argh[:uprate_walls] = true + argh[:wall_ut ] = 0.210 # (R27) json = TBD.process(model, argh) expect(json).to be_a(Hash) @@ -10597,118 +10577,132 @@ expect(json).to have_key(:surfaces) io = json[:io ] surfaces = json[:surfaces] - expect(TBD.status).to be_zero - expect(TBD.logs).to be_empty + + expect(TBD.warn?).to be true + expect(TBD.logs.size).to eq(1) expect(surfaces).to be_a(Hash) - expect(surfaces.size).to eq(65) # ! 56 before skylight/well/leader lines + expect(surfaces.size).to eq(23) expect(io).to be_a(Hash) expect(io).to have_key(:edges) - expect(io[:edges].size).to eq(115) # ! 106 before skylight/well/leader lines - - # Extra 9 edges: - # - 4x new "skylightjamb" edges - # - 4x new "transition" edges around well - # - 1x "transition" edge along leader line, required for well cutout. - sky_jambs = io[:edges].select { |ed| ed[:surfaces].include?(sky_id) } - expect(sky_jambs.size).to eq(4) - - sky_jambs.each do |edg| - expect(edg[:surfaces].size).to eq(2) - expect(edg[:surfaces]).to include(well_id) - expect(edg[:type]).to eq(:skylightjamb) - end + expect(io[:edges].size).to eq(300) - roof_edges = io[:edges].select { |ed| ed[:surfaces].include?(roof_id) } - parapets = roof_edges.select { |ed| ed[:type] == :parapetconvex } - transitions = roof_edges.select { |ed| ed[:type] == :transition } - expect(parapets.size).to eq(5) - expect(transitions.size).to eq(10) - expect(roof_edges.size).to eq(parapets.size + transitions.size) + msg = "Cloning '#{bulk}' construction - not '#{w1}' (TBD::uprate)" + expect(TBD.logs.first[:message]).to eq(msg) - parapets.each { |edg| expect(edg[:surfaces].size).to eq(2) } + bulk_roof = model.getSurfaceByName(bulk) + expect(bulk_roof).to_not be_empty + bulk_roof = bulk_roof.get - t1x = transitions.select { |edg| edg[:surfaces].size == 1 } - t2x = transitions.select { |edg| edg[:surfaces].size == 2 } - t4x = transitions.select { |edg| edg[:surfaces].size == 4 } - expect(t1x.size).to eq(1) # leader line - expect(t2x.size).to eq(5) # see "can process JSON surface KHI entries" - expect(t4x.size).to eq(4) # around skylight well + bulk_construction = bulk_roof.construction + expect(bulk_construction).to_not be_empty + bulk_construction = bulk_construction.get.to_LayeredConstruction + expect(bulk_construction).to_not be_empty + bulk_construction = bulk_construction.get + expect(bulk_construction.nameString).to eq("#{bulk} c tbd") + expect(bulk_construction.numLayers).to eq(3) # not 2 - expect(transitions.size).to eq(t1x.size + t2x.size + t4x.size) + layer0 = bulk_construction.layers[0] + layer1 = bulk_construction.layers[1] + layer2 = bulk_construction.layers[2] + expect(layer1.nameString).to eq("#{bulk} m tbd") # not uprated - # Skylight well cutout leader line backtracks onto itself. - t1x = t1x.first - expect(t1x[:surfaces]).to include(roof_id) + uA = 0 + m2 = 0 - t4x.each do |edg| - expect(edg[:surfaces].size).to eq(4) - expect(edg[:surfaces]).to include(roof_id) # roof with cutout - expect(edg[:surfaces]).to include(well_id) # new base surface for skylight + model.getSurfaces.each do |s| + next unless s.surfaceType.downcase == "wall" + next unless s.outsideBoundaryCondition.downcase == "outdoors" - edg[:surfaces].each do |s| - next if s == roof_id - next if s == well_id + expect(s.construction).to_not be_empty + expect(s.construction.get.to_LayeredConstruction).to_not be_empty + c = s.construction.get.to_LayeredConstruction.get + expect(c.numLayers).to eq(3) + expect(c.layers[0]).to eq(layer0) # same as Bulk Storage Roof + expect(c.layers[1].nameString).to include(" uprated ") + expect(c.layers[1].nameString).to include(" m tbd") + expect(c.layers[2]).to eq(layer2) # same as Bul;k Storage Roof - expect(s).to include("0:0:0:0:") - # e.g.: - # ... Level 0 Open area 1 Ceiling Plenum RoofCeiling (i.e. roof_id) - # ... 0:0:0:Open area 1 (i.e. well_id) - # ... 0:0:0:0:3:Level 0 Ceiling Plenum (i.e. well wall, plenum side) - # ... 0:0:0:0:3:Open area 1 (i.e. adjacent well wall, open area side) - end + m2 += s.netArea + uA += s.netArea / TBD.rsi(c, s.filmResistance) end - puts TBD.logs unless TBD.logs.empty? - expect(TBD.status).to be_zero + ut = uA / m2 + expect(ut.round(3)).to eq(0.210) # R27, per NECB 2017 requirements - file = File.join(__dir__, "files/osms/out/seb2_sky2.osm") + # TBD objects. + expect(surfaces).to have_key(bulk) + expect(surfaces[bulk]).to have_key(:net) + expect(surfaces[bulk]).to have_key(:heatloss) + expect(surfaces[bulk][:heatloss]).to be_within(TOL).of( 49.80) + expect(surfaces[bulk][:net ]).to be_within(TOL).of(3157.28) + expect(surfaces[bulk]).to have_key(:construction) # not yet derated + nom = surfaces[bulk][:construction].nameString + expect(nom).to include("cloned") + + file = File.join(__dir__, "files/osms/out/up3_warehouse.osm") model.save(file, true) end - it "can generate and access KIVA inputs (midrise apts)" do + it "can purge KIVA objects" do translator = OpenStudio::OSVersion::VersionTranslator.new TBD.clean! - file = File.join(__dir__, "files/osms/in/midrise.osm") + file = File.join(__dir__, "files/osms/out/seb_KIVA.osm") path = OpenStudio::Path.new(file) model = translator.loadModel(path) expect(model).to_not be_empty model = model.get - argh = {} - argh[:option ] = "poor (BETBG)" - argh[:gen_kiva] = true + expect(model.foundationKivaSettings).to be_empty + expect(model.getSurfacePropertyExposedFoundationPerimeters.size).to eq(1) + expect(model.getFoundationKivas.size).to eq(4) - 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.status).to be_zero - expect(TBD.logs).to be_empty - expect(surfaces).to be_a(Hash) - expect(surfaces.size).to eq(180) - expect(io).to be_a(Hash) - expect(io).to have_key(:edges) - expect(io[:edges].size).to eq(282) + adjacents = 0 + foundation = nil - # Validate. - surfaces.each do |id, surface| - next unless surface.key?(:foundation) # ... only floors - next unless surface.key?(:kiva) + model.getSurfaces.each do |surface| + next unless surface.isGroundSurface + next if surface.adjacentFoundation.empty? - expect(surface[:kiva]).to eq(:slab) - expect(surface).to have_key(:exposed) - expect(id).to eq("g Floor C") - expect(surface[:exposed]).to be_within(TOL).of(3.36) - gFC = model.getSurfaceByName("g Floor C") - expect(gFC).to_not be_empty - gFC = gFC.get - expect(gFC.outsideBoundaryCondition.downcase).to eq("foundation") + adjacents += 1 + foundation = surface.adjacentFoundation.get + expect(surface.surfacePropertyExposedFoundationPerimeter).to_not be_empty + expect(surface.outsideBoundaryCondition.downcase).to eq("foundation") end - file = File.join(__dir__, "files/osms/out/midrise_KIVA2.osm") + expect(adjacents).to eq(1) + expect(foundation).to be_a(OpenStudio::Model::FoundationKiva) + + # Add 2x custom blocks for testing. + xps = model.getMaterialByName("XPS_38mm") + expect(xps).to_not be_empty + xps = xps.get + expect(foundation.addCustomBlock(xps, 0.1, 0.1, -0.5)).to be true + expect(foundation.addCustomBlock(xps, 0.2, 0.2, -1.5)).to be true + + blocks = foundation.customBlocks + expect(blocks).to_not be_empty + + blocks.each { |block| expect(block.material).to eq(xps) } + + # Purge. + expect(TBD.resetKIVA(model, "Ground")).to be true + expect(model.foundationKivaSettings).to be_empty + expect(model.getSurfacePropertyExposedFoundationPerimeters).to be_empty + expect(model.getFoundationKivas).to be_empty + expect(TBD.info?).to be true + expect(TBD.logs.size).to eq(1) + expect(TBD.logs.first[:message]).to include("Purged KIVA objects from ") + + model.getSurfaces.each do |surface| + next unless surface.isGroundSurface + + expect(surface.adjacentFoundation).to be_empty + expect(surface.surfacePropertyExposedFoundationPerimeter).to be_empty + expect(surface.outsideBoundaryCondition).to eq("Ground") + end + + file = File.join(__dir__, "files/osms/out/seb_noKIVA.osm") model.save(file, true) end From d20b231046b1a8ca2d007069ff9fd5faded1034d Mon Sep 17 00:00:00 2001 From: brgix Date: Tue, 14 Apr 2026 08:00:32 -0400 Subject: [PATCH 07/10] Pulls OSut gem v090 --- Gemfile | 1 - tbd_tests.gemspec | 1 - 2 files changed, 2 deletions(-) diff --git a/Gemfile b/Gemfile index 8c2d34c..5576299 100644 --- a/Gemfile +++ b/Gemfile @@ -1,6 +1,5 @@ source "https://rubygems.org" -gem "osut", git: "https://github.com/rd2/osut", branch: "airfilm" gem "tbd", git: "https://github.com/rd2/tbd", branch: "airfilm" gemspec diff --git a/tbd_tests.gemspec b/tbd_tests.gemspec index f637135..56030db 100644 --- a/tbd_tests.gemspec +++ b/tbd_tests.gemspec @@ -27,7 +27,6 @@ Gem::Specification.new do |s| s.required_ruby_version = [">= 2.5.0", "< 4"] s.metadata = {} - s.add_development_dependency "osut", "~> 0.8.3" s.add_development_dependency "tbd", "~> 3.5.3" s.add_development_dependency "json-schema", "~> 4" s.add_development_dependency "rake", "~> 13.0" From 4f582f03f760ba0bf4539f0ad96c3de21d308bda Mon Sep 17 00:00:00 2001 From: brgix Date: Wed, 15 Apr 2026 09:34:31 -0400 Subject: [PATCH 08/10] Pulls latest TDB 'uo' fix --- spec/tbd_tests_spec.rb | 187 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 182 insertions(+), 5 deletions(-) diff --git a/spec/tbd_tests_spec.rb b/spec/tbd_tests_spec.rb index 236d30d..d900681 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 + + # Maintains 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 1f2e1416cdf86d77e42d47f0270586f1ab9f914f Mon Sep 17 00:00:00 2001 From: brgix Date: Thu, 16 Apr 2026 09:50:55 -0400 Subject: [PATCH 09/10] Pulls latest TBD uprating fixes --- spec/tbd_tests_spec.rb | 45 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 41 insertions(+), 4 deletions(-) diff --git a/spec/tbd_tests_spec.rb b/spec/tbd_tests_spec.rb index d900681..137cd31 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)" } @@ -10754,8 +10792,7 @@ expect(json).to have_key(:surfaces) io = json[:io ] surfaces = json[:surfaces] - - expect(TBD.warn?).to be true + expect(TBD.info?).to be true expect(TBD.logs.size).to eq(1) expect(surfaces).to be_a(Hash) expect(surfaces.size).to eq(23) From 998f35adb5655dc8fda97e50312c5789d1bcee0d Mon Sep 17 00:00:00 2001 From: brgix Date: Mon, 20 Apr 2026 09:34:08 -0400 Subject: [PATCH 10/10] Pulls TBD v360 gem (dev dependency) --- Gemfile | 2 +- tbd_tests.gemspec | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile b/Gemfile index 5576299..a62691e 100644 --- a/Gemfile +++ b/Gemfile @@ -1,5 +1,5 @@ source "https://rubygems.org" -gem "tbd", git: "https://github.com/rd2/tbd", branch: "airfilm" +gem "tbd", git: "https://github.com/rd2/tbd", branch: "develop" gemspec diff --git a/tbd_tests.gemspec b/tbd_tests.gemspec index 56030db..f650dfd 100644 --- a/tbd_tests.gemspec +++ b/tbd_tests.gemspec @@ -27,7 +27,7 @@ Gem::Specification.new do |s| s.required_ruby_version = [">= 2.5.0", "< 4"] s.metadata = {} - s.add_development_dependency "tbd", "~> 3.5.3" + s.add_development_dependency "tbd", "~> 3.6.0" s.add_development_dependency "json-schema", "~> 4" s.add_development_dependency "rake", "~> 13.0" s.add_development_dependency "rspec", "~> 3.11"