diff --git a/lib/measures/tbd/measure.xml b/lib/measures/tbd/measure.xml
index c24d529..b3f705d 100644
--- a/lib/measures/tbd/measure.xml
+++ b/lib/measures/tbd/measure.xml
@@ -3,8 +3,8 @@
3.1
tbd_measure
8890787b-8c25-4dc8-8641-b6be1b6c2357
- 833dbf17-e51f-43c7-9c8b-a79ef0e6bd3b
- 2026-02-03T13:53:54Z
+ 4934dc58-443d-436f-9358-2d221b4de65a
+ 2026-04-17T10:47:02Z
99772807
TBDMeasure
Thermal Bridging and Derating - TBD
@@ -499,7 +499,7 @@
geo.rb
rb
resource
- 9CA80CEB
+ AD9546E1
geometry.rb
@@ -523,7 +523,7 @@
psi.rb
rb
resource
- B9FB5E02
+ 9F7B97ED
tbd.rb
@@ -541,13 +541,13 @@
ua.rb
rb
resource
- D3A2A391
+ 0FB3F654
utils.rb
rb
resource
- 118F3A32
+ 26EC8C4F
version.rb
diff --git a/lib/measures/tbd/resources/geo.rb b/lib/measures/tbd/resources/geo.rb
index 00aeae4..905bade 100644
--- a/lib/measures/tbd/resources/geo.rb
+++ b/lib/measures/tbd/resources/geo.rb
@@ -299,14 +299,16 @@ def properties(surface = nil, argh = {})
return invalid("#{nom} normal", mth, 0, ERR) unless n
type = surface.surfaceType.downcase
- facing = surface.outsideBoundaryCondition
+ facing = surface.outsideBoundaryCondition.downcase
+ interz = false
setpts = setpoints(space)
- if facing.downcase == "surface"
- empty = surface.adjacentSurface.empty?
- return invalid("#{nom}: adjacent surface", mth, 0, ERR) if empty
+ if facing == "surface"
+ adj = surface.adjacentSurface
+ return invalid("#{nom}: adjacent surface", mth, 0, ERR) if adj.empty?
- facing = surface.adjacentSurface.get.nameString
+ facing = adj.get.nameString
+ interz = true
end
unless surface.construction.empty?
@@ -315,8 +317,9 @@ def properties(surface = nil, argh = {})
unless lc.empty?
lc = lc.get
lyr = insulatingLayer(lc)
+ idx = lyr[:index]
- if lyr[:index].is_a?(Integer) && lyr[:index].between?(0, lc.numLayers - 1)
+ if idx.is_a?(Integer) && idx.between?(0, lc.numLayers - 1)
surf[:construction] = lc
# index: ... of layer/material (to derate) within construction
# ltype: either :massless (RSi) or :standard (k + d)
@@ -358,8 +361,14 @@ def properties(surface = nil, argh = {})
surf[:story ] = story.get unless story.empty?
surf[:n ] = n
surf[:gross ] = surface.grossArea
- surf[:filmRSI ] = surface.filmResistance
surf[:spandrel ] = spandrel?(surface)
+ surf[:filmRSI ] = surface.filmResistance
+
+ if interz
+ typ = :ceiling # interzone roof or ceiling
+ typ = :partition if surf[:type] == :wall
+ surf[:filmRSI] = TBD.filmResistances(typ, surface.tilt)
+ end
surface.subSurfaces.sort_by { |s| s.nameString }.each do |s|
next if poly(s).empty?
@@ -508,7 +517,7 @@ def properties(surface = nil, argh = {})
end
unless u.is_a?(Numeric)
- r = rsi(c, surface.filmResistance)
+ r = rsi(c, surf[:filmRSI])
if r < TOL
log(ERR, "Skipping '#{id}': U-factor unavailable (#{mth})")
@@ -831,7 +840,7 @@ def kiva(model = nil, walls = {}, floors = {}, edges = {})
edge[:surfaces].keys.each do |id|
next unless floors.key?(id)
- next unless floors[id][:boundary].downcase == "foundation"
+ next unless floors[id][:boundary] == "foundation"
next if floors[id].key?(:kiva)
# Initially set as slab-on-grade. Track 'exposed foundation perimeter'.
@@ -845,7 +854,7 @@ def kiva(model = nil, walls = {}, floors = {}, edges = {})
edge[:surfaces].keys.each do |i|
next if i == id
next unless walls.key?(i)
- next unless walls[i][:boundary].downcase == "foundation"
+ next unless walls[i][:boundary] == "foundation"
next if walls[i].key?(:kiva)
floors[id][:kiva ] = :basement
@@ -857,7 +866,7 @@ def kiva(model = nil, walls = {}, floors = {}, edges = {})
edge[:surfaces].keys.each do |i|
next if i == id
next unless walls.key?(i)
- next unless walls[i][:boundary].downcase == "outdoors"
+ next unless walls[i][:boundary] == "outdoors"
floors[id][:exposed] += edge[:length]
end
@@ -872,7 +881,7 @@ def kiva(model = nil, walls = {}, floors = {}, edges = {})
e[:surfaces].keys.each do |ii|
next if i == ii
next unless walls.key?(ii)
- next unless walls[ii][:boundary].downcase == "foundation"
+ next unless walls[ii][:boundary] == "foundation"
next if walls[ii].key?(:kiva)
floors[id][:kiva ] = :basement
@@ -883,7 +892,7 @@ def kiva(model = nil, walls = {}, floors = {}, edges = {})
e[:surfaces].keys.each do |ii|
next if i == ii
next unless walls.key?(ii)
- next unless walls[ii][:boundary].downcase == "outdoors"
+ next unless walls[ii][:boundary] == "outdoors"
floors[id][:exposed] += e[:length]
end
diff --git a/lib/measures/tbd/resources/psi.rb b/lib/measures/tbd/resources/psi.rb
index 7c37714..dba602b 100644
--- a/lib/measures/tbd/resources/psi.rb
+++ b/lib/measures/tbd/resources/psi.rb
@@ -1424,8 +1424,10 @@ def derate(id = "", s = {}, lc = nil)
m = m.clone(model).to_MasslessOpaqueMaterial.get
m.setName("#{id} #{up}m tbd")
- de_r = RMIN unless de_r > RMIN
- loss = (de_u - 1 / de_r) * net unless de_r > RMIN
+ if de_r < RMIN
+ de_r = RMIN
+ loss = (de_u - 1 / de_r) * net
+ end
unless m.setThermalResistance(de_r)
return invalid("Can't derate #{id}: RSi#{de_r.round(2)}", mth)
@@ -1555,7 +1557,7 @@ def process(model = nil, argh = {})
next unless surface[:conditioned]
next if surface[:ground ]
- unless surface[:boundary].downcase == "outdoors"
+ unless surface[:boundary] == "outdoors"
next unless tbd[:surfaces].key?(surface[:boundary])
next if tbd[:surfaces][surface[:boundary]][:conditioned]
end
@@ -2202,7 +2204,7 @@ def process(model = nil, argh = {})
next if holes.key?(i)
next if shades.key?(i)
- facing = tbd[:surfaces][i][:boundary].downcase
+ facing = tbd[:surfaces][i][:boundary]
next unless facing == "othersidecoefficients"
s1 = edge[:surfaces][id]
@@ -2971,6 +2973,7 @@ def process(model = nil, argh = {})
# derate a construction/material pair having " tbd" in their OpenStudio name.
tbd[:surfaces].each do |id, surface|
next unless surface.key?(:construction)
+ next unless surface.key?(:filmRSI)
next unless surface.key?(:index)
next unless surface.key?(:ltype)
next unless surface.key?(:r)
@@ -2981,8 +2984,7 @@ def process(model = nil, argh = {})
s = model.getSurfaceByName(id)
next if s.empty?
- s = s.get
-
+ s = s.get
index = surface[:index ]
current_c = surface[:construction]
c = current_c.clone(model).to_LayeredConstruction.get
@@ -2995,7 +2997,7 @@ def process(model = nil, argh = {})
if m
c.setLayer(index, m)
c.setName("#{id} c tbd")
- current_R = rsi(current_c, s.filmResistance)
+ current_R = rsi(current_c, surface[:filmRSI])
# In principle, the derated "ratio" could be calculated simply by
# accessing a surface's uFactor. Yet air layers within constructions
@@ -3035,7 +3037,7 @@ def process(model = nil, argh = {})
# Compute updated RSi value from layers.
updated_c = s.construction.get.to_LayeredConstruction.get
- updated_R = rsi(updated_c, s.filmResistance)
+ updated_R = rsi(updated_c, surface[:filmRSI])
ratio = -(current_R - updated_R) * 100 / current_R
surface[:ratio] = ratio if ratio.abs > TOL
@@ -3047,14 +3049,10 @@ def process(model = nil, argh = {})
tbd[:surfaces].each do |id, surface|
next unless surface[:deratable]
next unless surface.key?(:construction)
+ next unless surface.key?(:filmRSI)
next if surface.key?(:u)
- s = model.getSurfaceByName(id)
- msg = "Skipping missing surface '#{id}' (#{mth})"
- log(ERR, msg) if s.empty?
- next if s.empty?
-
- surface[:u] = 1.0 / rsi(surface[:construction], s.get.filmResistance)
+ surface[:u] = 1.0 / rsi(surface[:construction], surface[:filmRSI])
end
json[:io][:edges] = []
@@ -3204,8 +3202,8 @@ def exit(runner = nil, argh = {})
uo = format("%.3f", g[:uo])
ut = format("%.3f", g[:ut])
- output = "An initial #{label.to_s} Uo of #{uo} W/m2•K is required to " \
- "achieve an overall Ut of #{ut} W/m2•K for #{g[:op]}"
+ output = "An area-weighted #{label.to_s} Uo of #{uo} W/m2•K is " \
+ "required to meet an overall Ut of #{ut} W/m2•K for #{g[:op]}"
u_t << output
runner.registerInfo(output)
end
diff --git a/lib/measures/tbd/resources/ua.rb b/lib/measures/tbd/resources/ua.rb
index c4caad3..994d0b5 100644
--- a/lib/measures/tbd/resources/ua.rb
+++ b/lib/measures/tbd/resources/ua.rb
@@ -25,103 +25,113 @@ module TBD
# Calculates construction Uo (including surface film resistances) to meet Ut.
#
# @param model [OpenStudio::Model::Model] a model
- # @param lc [OpenStudio::Model::LayeredConstruction] a layered construction
# @param id [#to_s] layered construction identifier
- # @param hloss [Numeric] heat loss from major thermal bridging, in W/K
+ # @param lc [OpenStudio::Model::LayeredConstruction] a layered construction
+ # @param area [Numeric] net surface area covered by layered construction
# @param film [Numeric] target surface film resistance, in m2•K/W
+ # @param hloss [Numeric] heat loss from major thermal bridging, in W/K
# @param ut [Numeric] target overall Ut for lc, in W/m2•K
#
- # @return [Hash] uo: lc Uo [W/m2•K] to meet Ut, m: uprated lc layer
- # @return [Hash] uo: (nil), m: (nil) if invalid input (see logs)
- def uo(model = nil, lc = nil, id = "", hloss = 0.0, film = 0.0, ut = 0.0)
+ # @return [Float] Uo [W/m2•K] required to meet Ut (see logs if 0 or UMIN)
+ def uo(id = "", lc = nil, area = 0, film = 0, hloss = 0, ut = 0)
mth = "TBD::#{__callee__}"
- res = { uo: nil, m: nil }
- cl1 = OpenStudio::Model::Model
- cl2 = OpenStudio::Model::LayeredConstruction
- cl3 = Numeric
- cl4 = String
+ cl1 = OpenStudio::Model::LayeredConstruction
+ cl2 = Numeric
+ cl3 = String
id = trim(id)
- return mismatch("model", model, cl1, mth, DBG, res) unless model.is_a?(cl1)
- return mismatch("id" , id, cl4, mth, DBG, res) if id.empty?
- return mismatch("lc" , lc, cl2, mth, DBG, res) unless lc.is_a?(cl2)
- return mismatch("hloss", hloss, cl3, mth, DBG, res) unless hloss.is_a?(cl3)
- return mismatch("film" , film, cl3, mth, DBG, res) unless film.is_a?(cl3)
- return mismatch("Ut" , ut, cl3, mth, DBG, res) unless ut.is_a?(cl3)
-
- loss = 0.0 # residual heatloss (not assigned) [W/K]
- area = lc.getNetArea
- lyr = insulatingLayer(lc)
+ return mismatch("id" , id, cl3, mth, DBG, 0) if id.empty?
+ return mismatch("lc" , lc, cl1, mth, DBG, 0) unless lc.is_a?(cl1)
+ return mismatch("area" , area, cl2, mth, DBG, 0) unless area.is_a?(cl2)
+ return mismatch("film" , film, cl2, mth, DBG, 0) unless film.is_a?(cl2)
+ return mismatch("hloss", hloss, cl2, mth, DBG, 0) unless hloss.is_a?(cl2)
+ return mismatch("Ut" , ut, cl2, mth, DBG, 0) unless ut.is_a?(cl2)
+
+ # Residual heatloss (not assigned) [W/K].
+ model = lc.model
+ loss = 0
+ lyr = insulatingLayer(lc)
+
+ # Validate insulating layer.
lyr[:index] = nil unless lyr[:index].is_a?(Numeric)
lyr[:index] = nil unless lyr[:index] >= 0
lyr[:index] = nil unless lyr[:index] < lc.layers.size
- return invalid("#{id} layer index", mth, 3, WRN, res) unless lyr[:index]
- return zero("#{id}: heatloss" , mth, WRN, res) unless hloss > TOL
- return zero("#{id}: films" , mth, WRN, res) unless film > TOL
- return zero("#{id}: Ut" , mth, WRN, res) unless ut > UMIN
- return invalid("#{id}: Ut" , mth, 6, WRN, res) unless ut < UMAX
- return zero("#{id}: net area (m2)", mth, WRN, res) unless area > TOL
-
- # First, calculate initial layer RSi to initially meet Ut target.
- rt = 1 / ut # target construction Rt
- ro = rsi(lc, film) # current construction Ro
- new_r = lyr[:r] + (rt - ro) # new, un-derated layer RSi
- new_u = 1 / new_r
-
- # Then, uprate (if possible) to counter expected thermal bridging effects.
- u_psi = hloss / area # from psi+khi
- new_u -= u_psi # uprated layer USi to counter psi+khi
- new_r = 1 / new_u # uprated layer RSi to counter psi+khi
- return zero("#{id}: new Rsi", mth, WRN, res) unless new_r > RMIN
+ return invalid("#{id} layer index", mth, 3, DBG, 0) unless lyr[:index]
+ return zero("#{id}: net area (m2)", mth, DBG, 0) unless area > TOL
+ return negative("#{id}: film RSI" , mth, DBG, 0) if film < 0
+ return zero("#{id}: heatloss" , mth, DBG, 0) if hloss < TOL
+ return zero("#{id}: Ut" , mth, DBG, 0) unless ut > UMIN
+ return invalid("#{id}: Ut" , mth, 4, DBG, 0) unless ut < UMAX
+
+ # Calculate initial layer RSi to initially meet Ut target.
+ rt = 1 / ut # target construction Rt
+ r0 = rsi(lc, film) # current construction R0
+ r = lyr[:r] + rt - r0 # new, un-derated layer RSi
+
+ # Adjust if below admissible threshold.
+ if r < 0
+ zero("#{id}: layer RSI", mth, INF)
+ r = RMIN
+ end
+
+ # Uprate to counter heat loss from thermal bridging.
+ u = 1 / r
+ u -= (hloss / area)
+
+ # Adjust if beyond admissible range.
+ if u < UMIN
+ negative("#{id}: new Uo", mth, INF)
+ u = UMIN
+ end
+
+ r = 1 / u
if lyr[:type] == :massless
m = lc.getLayer(lyr[:index]).to_MasslessOpaqueMaterial
- return invalid("#{id} massless layer?", mth, 0, DBG, res) if m.empty?
+ return invalid("#{id} massless layer?", mth, 0, DBG, 0) if m.empty?
m = m.get.clone(model).to_MasslessOpaqueMaterial.get
m.setName("#{id} uprated")
- new_r = RMIN unless new_r > RMIN
- loss = (new_u - 1 / new_r) * area unless new_r > RMIN
+ if r < RMIN
+ r = RMIN
+ loss = (u - 1 / r) * area
+ end
- unless m.setThermalResistance(new_r)
- return invalid("Can't uprate #{id}: RSi#{new_r.round(2)}", mth, 0, DBG, res)
+ unless m.setThermalResistance(r)
+ return invalid("Can't uprate #{id}: RSi#{r.round(2)}", mth, 0, DBG, 0)
end
else
m = lc.getLayer(lyr[:index]).to_StandardOpaqueMaterial
- return invalid("#{id} standard layer?", mth, 0, DBG, res) if m.empty?
+ return invalid("#{id} standard layer?", mth, 0, DBG, 0) if m.empty?
m = m.get.clone(model).to_StandardOpaqueMaterial.get
m.setName("#{id} uprated")
d = m.thickness
- k = (d / new_r).clamp(KMIN, KMAX)
- d = (k * new_r).clamp(DMIN, DMAX)
+ k = (d / r).clamp(KMIN, KMAX)
+ d = (k * r).clamp(DMIN, DMAX)
- loss = (new_u - k / d) * area unless d / k > RMIN
+ loss = (u - k / d) * area if d / k < RMIN
unless m.setThermalConductivity(k)
- return invalid("Can't uprate #{id}: K#{k.round(3)}", mth, 0, DBG, res)
+ return invalid("Can't uprate #{id}: K#{k.round(3)}", mth, 0, DBG, 0)
end
unless m.setThickness(d)
- return invalid("Can't uprate #{id}: #{(d*1000).to_i}mm", mth, 0, DBG, res)
+ return invalid("Can't uprate #{id}: #{(d*1000).to_i}mm", mth, 0, DBG, 0)
end
end
- return invalid("Can't ID insulating layer", mth, 0, DBG, res) unless m
+ return invalid("Can't ID insulating layer", mth, 0, DBG, 0) unless m
lc.setLayer(lyr[:index], m)
- uo = 1 / rsi(lc, film)
-
- if loss > TOL
- h_loss = format "%.3f", loss
- return invalid("Can't assign #{h_loss} W/K to #{id}", mth, 0, DBG, res)
- end
+ ro = rsi(lc, film)
+ uo = ro < RMIN ? UMIN : 1 / ro
- res[:uo] = uo
- res[:m ] = m
+ h = format "%.3f", loss
+ log(INF, "Can't set #{h} W/K to #{id} #{mth}") if loss > TOL
- res
+ uo
end
##
@@ -185,230 +195,219 @@ def uprate(model = nil, s = {}, argh = {})
groups[:roof ][:ut] = argh[:roof_ut ]
groups[:floor][:ut] = argh[:floor_ut ]
- groups[:wall ][:op] = trim(argh[:wall_option ])
- groups[:roof ][:op] = trim(argh[:roof_option ])
- groups[:floor][:op] = trim(argh[:floor_option ])
+ groups[:wall ][:op] = trim(argh[:wall_option ])
+ groups[:roof ][:op] = trim(argh[:roof_option ])
+ groups[:floor][:op] = trim(argh[:floor_option])
+ # Group and process walls, roofs and floors sequentially/independently.
groups.each do |type, g|
next unless g[:up]
next unless g[:ut].is_a?(Numeric)
next unless g[:ut] < UMAX
- next if g[:ut] < 0
+ next unless g[:ut] > UMIN
- typ = type
- typ = :ceiling if typ == :roof
+ typ = type
+ typ = :ceiling if typ == :roof
+
+ # Collection of one or several constructions to uprate.
coll = {}
- area = 0
- film = 100000000000000
- lc = nil
- id = ""
- op = g[:op].downcase
- all = tout.include?(op)
-
- if g[:op].empty?
- log(WRN, "Construction (#{type}) to uprate? (#{mth})")
- elsif all
+ op = g[:op]
+
+ # Uprate ALL constructions of same type, e.g. walls.
+ if tout.include?(op.downcase)
s.each do |nom, surface|
- next unless surface.key?(:deratable )
- next unless surface.key?(:type )
+ next unless surface.key?(:deratable)
+ next unless surface.key?(:type)
next unless surface.key?(:construction)
- next unless surface.key?(:filmRSI )
- next unless surface.key?(:index )
- next unless surface.key?(:ltype )
- next unless surface.key?(:r )
+ next unless surface.key?(:filmRSI)
+ next unless surface.key?(:ltype)
+ next unless surface.key?(:r)
+ next unless surface.key?(:index)
+ next unless surface.key?(:net)
next unless surface[:deratable ]
next unless surface[:type ] == typ
next unless surface[:construction].is_a?(cl3)
next if surface[:index ].nil?
- # Retain lowest surface film resistance (e.g. tilted surfaces).
- c = surface[:construction]
- i = c.nameString
- aire = c.getNetArea
- film = surface[:filmRSI] if surface[:filmRSI] < film
-
- # Retain construction covering largest area. The following conditional
- # is reliable UNLESS linked to other deratable surface types e.g. both
- # floors AND walls (see "elsif lc" corrections below).
- if aire > area
- lc = c
- area = aire
- id = i
+ # Collect constructions to uprate.
+ lc = surface[:construction]
+ id = lc.nameString
+
+ # Track construction-specific parameters.
+ unless coll.key?(id)
+ coll[id] = {}
+ coll[id][:lc ] = lc
+ coll[id][:s ] = {}
+ coll[id][:hloss] = 0
+ coll[id][:area ] = 0
+ coll[id][:film ] = 0
+ coll[id][:fA ] = 0
+ coll[id][:uA ] = 0
+ coll[id][:u0 ] = 0
end
- coll[i] = { area: aire, lc: c, s: {} } unless coll.key?(i)
- coll[i][:s][nom] = { a: surface[:net] } unless coll[i][:s].key?(nom)
+ coll[id][:idx] = surface[:index] unless coll[id].key?(:idx)
+ coll[id][:ltp] = surface[:ltype] unless coll[id].key?(:ltp)
+
+ # Track surface-specific parameters.
+ unless coll[id][:s].key?(nom)
+ coll[id][:s][nom] = {}
+ coll[id][:s][nom][:a] = surface[:net]
+ coll[id][:s][nom][:f] = surface[:filmRSI]
+ coll[id][:s][nom][:h] = 0
+ next unless surface.key?(:heatloss)
+ next unless surface[:heatloss].abs > TOL
+
+ coll[id][:s][nom][:h] = surface[:heatloss]
+ end
end
else
- id = g[:op]
+ id = op # single, user-selected construction
lc = model.getConstructionByName(id)
- log(WRN, "Construction '#{id}'? (#{mth})") if lc.empty?
- next if lc.empty?
+
+ if lc.empty?
+ log(WRN, "Construction '#{id}'? (#{mth})")
+ next
+ end
lc = lc.get.to_LayeredConstruction
- log(WRN, "'#{id}' layered construction? (#{mth})") if lc.empty?
- next if lc.empty?
- lc = lc.get
- area = lc.getNetArea
- coll[id] = { area: area, lc: lc, s: {} }
+ if lc.empty?
+ log(WRN, "'#{id}' layered construction? (#{mth})")
+ next
+ end
+
+ lc = lc.get
+
+ coll[id] = {}
+ coll[id][:lc ] = lc
+ coll[id][:s ] = {}
+ coll[id][:hloss] = 0
+ coll[id][:area ] = 0
+ coll[id][:film ] = 0
+ coll[id][:fA ] = 0
+ coll[id][:uA ] = 0
+ coll[id][:u0 ] = 0
s.each do |nom, surface|
- next unless surface.key?(:deratable )
- next unless surface.key?(:type )
+ next unless surface.key?(:deratable)
+ next unless surface.key?(:type)
next unless surface.key?(:construction)
- next unless surface.key?(:filmRSI )
- next unless surface.key?(:index )
- next unless surface.key?(:ltype )
- next unless surface.key?(:r )
+ next unless surface.key?(:filmRSI)
+ next unless surface.key?(:ltype)
+ next unless surface.key?(:r)
+ next unless surface.key?(:index)
+ next unless surface.key?(:net)
next unless surface[:deratable ]
next unless surface[:type ] == typ
next unless surface[:construction].is_a?(cl3)
+ next unless surface[:construction].nameString == id
next if surface[:index ].nil?
- i = surface[:construction].nameString
- next unless i == id
-
- # Retain lowest surface film resistance (e.g. tilted surfaces).
- film = surface[:filmRSI] if surface[:filmRSI] < film
-
- coll[i][:s][nom] = { a: surface[:net] } unless coll[i][:s].key?(nom)
+ coll[id][:idx] = surface[:index] unless coll[id].key?(:idx)
+ coll[id][:ltp] = surface[:ltype] unless coll[id].key?(:ltp)
+
+ # Track (for surfaces of targeted type):
+ # - net area
+ # - air film resistances
+ unless coll[id][:s].key?(nom)
+ coll[id][:s][nom] = {}
+ coll[id][:s][nom][:a] = surface[:net]
+ coll[id][:s][nom][:f] = surface[:filmRSI]
+ coll[id][:s][nom][:h] = 0
+ next unless surface.key?(:heatloss)
+ next unless surface[:heatloss].abs > TOL
+
+ coll[id][:s][nom][:h] = surface[:heatloss]
+ end
end
end
if coll.empty?
- log(WRN, "No #{type} construction to uprate - skipping (#{mth})")
+ log(WRN, "Unable to uprate #{type} construction - skipping (#{mth})")
next
- elsif lc
- # Valid layered construction - good to uprate!
- lyr = insulatingLayer(lc)
- lyr[:index] = nil unless lyr[:index].is_a?(Numeric)
- lyr[:index] = nil unless lyr[:index] >= 0
- lyr[:index] = nil unless lyr[:index] < lc.layers.size
-
- log(WRN, "Insulation index for '#{id}'? (#{mth})") unless lyr[:index]
- next unless lyr[:index]
-
- # Ensure lc is exclusively linked to deratable surfaces of right type.
- # If not, assign new lc clone to non-targeted surfaces.
- s.each do |nom, surface|
- next unless surface.key?(:type )
- next unless surface.key?(:deratable )
- next unless surface.key?(:construction)
- next unless surface[:construction].is_a?(cl3)
- next unless surface[:construction] == lc
- next unless surface[:deratable]
-
- ok = true
- ok = false unless surface[:type] == typ
- ok = false unless coll.key?(id)
- ok = false unless coll[id][:s].key?(nom)
-
- unless ok
- log(WRN, "Cloning '#{nom}' construction - not '#{id}' (#{mth})")
- sss = model.getSurfaceByName(nom)
- next if sss.empty?
-
- sss = sss.get
+ else
+ coll.each do |id, col|
+ lc = col[:lc]
+
+ # Ensure lc is exclusively linked to deratable surfaces of targeted
+ # type. If not, assign new lc clone to non-targeted surfaces.
+ s.each do |nom, surface|
+ next unless surface.key?(:deratable)
+ next unless surface.key?(:type)
+ next unless surface.key?(:construction)
+ next unless surface.key?(:filmRSI)
+ next unless surface.key?(:ltype)
+ next unless surface.key?(:r)
+ next unless surface.key?(:index)
+ next unless surface.key?(:net)
+ next unless surface[:deratable]
+ next unless surface[:construction].is_a?(cl3)
+ next unless surface[:construction] == lc
+ next if surface[:index ].nil?
+ next if surface[:type ] == typ
+ next if coll[id][:s].key?(nom)
+
+ log(INF, "Cloning '#{nom}' construction - not '#{id}' (#{mth})")
+ srf = model.getSurfaceByName(nom)
+ next if srf.empty?
+
+ srf = srf.get
cloned = lc.clone(model).to_LayeredConstruction.get
cloned.setName("#{nom} - cloned")
- sss.setConstruction(cloned)
+ srf.setConstruction(cloned)
surface[:construction] = cloned
- coll[id][:s].delete(nom)
end
end
- hloss = 0 # sum of applicable psi+khi-related losses [W/K]
-
- # Tally applicable psi+khi losses. Possible construction reassignment.
- coll.each do |i, col|
- col[:s].keys.each do |nom|
- next unless s.key?(nom)
- next unless s[nom].key?(:construction)
- next unless s[nom].key?(:index)
- next unless s[nom].key?(:ltype)
- next unless s[nom].key?(:r)
-
- # Tally applicable psi+khi.
- hloss += s[nom][:heatloss ] if s[nom].key?(:heatloss)
- next if s[nom][:construction] == lc
-
- # Reassign construction unless referencing lc.
- sss = model.getSurfaceByName(nom)
- next if sss.empty?
-
- sss = sss.get
-
- if sss.isConstructionDefaulted
- set = defaultConstructionSet(sss) # building? story?
-
- if set.nil?
- sss.setConstruction(lc)
- else
- constructions = set.defaultExteriorSurfaceConstructions
-
- unless constructions.empty?
- constructions = constructions.get
- constructions.setWallConstruction(lc) if typ == :wall
- constructions.setFloorConstruction(lc) if typ == :floor
- constructions.setRoofCeilingConstruction(lc) if typ == :ceiling
- end
- end
- else
- sss.setConstruction(lc)
- end
+ coll.each do |id, col|
+ col[:s].values.each do |item|
+ col[:hloss] += item[:h]
+ col[:area ] += item[:a]
+ col[:fA ] += item[:a] / item[:f] unless item[:f] < 0
+ end
- s[nom][:construction] = lc # reset TBD attributes
- s[nom][:index ] = lyr[:index]
- s[nom][:ltype ] = lyr[:type ]
- s[nom][:r ] = lyr[:r ] # temporary
+ if col[:area] < TOL
+ empty("#{id} area", mth, WRN)
+ next
end
- end
- # Merge to ensure a single entry for coll Hash.
- coll.each do |i, col|
- next if i == id
+ # Area-weighted surface air film resistances.
+ col[:film] = 1 / (col[:fA] / col[:area])
- col[:s].each do |nom, sss|
- coll[id][:s][nom] = sss unless coll[id][:s].key?(nom)
+ # Fetch required, uprated Uo.
+ u = uo(id, col[:lc], col[:area], col[:film], col[:hloss], g[:ut])
+
+ unless u > UMIN
+ log(WRN, "Unable to completely uprate '#{id}' (#{mth})")
+ u = UMIN
end
- end
- coll.delete_if { |i, _| i != id }
+ col[:u ] = u
+ col[:uA] = u * col[:area]
- unless coll.size == 1
- log(DBG, "Collection == 1? for '#{id}' (#{mth})")
- next
- end
+ # Recoup uprated construction and insulating layer.
+ lc = col[:lc]
+ lyr = insulatingLayer(lc)
- coll[id][:area] = lc.getNetArea
- res = uo(model, lc, id, hloss, film, g[:ut])
+ # Reset surface :r (uprated RSi of insulation, before derating).
+ col[:s].keys.each do |nom|
+ next unless s.key?(nom)
+ next unless s[nom].key?(:r)
- unless res[:uo] && res[:m]
- log(WRN, "Unable to uprate '#{id}' (#{mth})")
- next
+ s[nom][:r] = lyr[:r]
+ end
end
- lyr = insulatingLayer(lc)
-
- # Loop through coll :s, and reset :r - likely modified by uo().
- coll.values.first[:s].keys.each do |nom|
- next unless s.key?(nom)
- next unless s[nom].key?(:index)
- next unless s[nom].key?(:ltype)
- next unless s[nom].key?(:r )
- next unless s[nom][:index] == lyr[:index]
- next unless s[nom][:ltype] == lyr[:type ]
+ # Store UA-averaged, upgraded Uo-factor per type.
+ area = coll.values.sum { |col| col[:area] }
+ uA = coll.values.sum { |col| col[:uA ] }
- s[nom][:r] = lyr[:r] # uprated insulating RSi factor, before derating
+ if area > TOL
+ argh[:wall_uo ] = uA / area if typ == :wall
+ argh[:roof_uo ] = uA / area if typ == :ceiling
+ argh[:floor_uo] = uA / area if typ == :floor
end
-
- argh[:wall_uo ] = res[:uo] if typ == :wall
- argh[:roof_uo ] = res[:uo] if typ == :ceiling
- argh[:floor_uo] = res[:uo] if typ == :floor
- else
- log(WRN, "Nilled construction to uprate - (#{mth})")
- return false
end
end
@@ -442,54 +441,57 @@ def qc33(s = {}, sets = nil, spts = true)
return mismatch("sets", sets, cl1, mth, DBG, false) unless sets.is_a?(cl2)
shorts = sets.shorthands("code (Quebec)")
- empty = shorts[:has].empty? || shorts[:val].empty?
- log(DBG, "Missing QC PSI set for 3.3 UA' tradeoff (#{mth})") if empty
- return false if empty
- ok = [true, false].include?(spts)
- log(DBG, "setpoints must be true or false for 3.3 UA' tradeoff") unless ok
- return false unless ok
+ if shorts[:has].empty? || shorts[:val].empty?
+ log(DBG, "Missing QC PSI set for 3.3 UA' tradeoff (#{mth})")
+ return false
+ end
+
+ unless [true, false].include?(spts)
+ log(DBG, "setpoints must be true or false for 3.3 UA' tradeoff")
+ return false
+ end
s.each do |id, surface|
next unless surface.key?(:deratable)
next unless surface[:deratable]
next unless surface.key?(:type)
- heating = -50 if spts
- cooling = 50 if spts
- heating = 21 unless spts
- cooling = 24 unless spts
- heating = surface[:heating] if surface.key?(:heating)
- cooling = surface[:cooling] if surface.key?(:cooling)
+ htng = spts ? -24 : 21
+ clng = spts ? 50 : 24
+ htng = surface[:heating] if surface.key?(:heating)
+ clng = surface[:cooling] if surface.key?(:cooling)
- # Start with surface U-factors.
- ref = 1 / 5.46
- ref = 1 / 3.60 if surface[:type] == :wall
+ # Avoid 'divide by zero' case.
+ htng = -24 if htng < -24
- # Adjust for lower heating setpoint (assumes -25C design conditions).
- ref *= 43 / (heating + 25) if heating < 18 && cooling > 40
+ # Start with surface U-factors. Adjust for lower heating setpoint.
+ # Assumes -25C design conditions.
+ ref = ( surface[:type] == :wall ) ? (1 / 3.60) : (1 / 5.46)
+ ref *= 43 / (htng + 25) if htng > -25 && htng < 18 && clng > 40
surface[:ref] = ref
- if surface.key?(:skylights) # loop through subsurfaces
- ref = 2.85
- ref *= 43 / (heating + 25) if heating < 18 && cooling > 40
+ # Loop through subsurfaces.
+ if surface.key?(:skylights)
+ ref = 2.85
+ ref *= 43 / (htng + 25) if htng > -25 && htng < 18 && clng > 40
surface[:skylights].values.map { |skylight| skylight[:ref] = ref }
end
if surface.key?(:windows)
- ref = 2.0
- ref *= 43 / (heating + 25) if heating < 18 && cooling > 40
+ ref = 2.0
+ ref *= 43 / (htng + 25) if htng > -25 && htng < 18 && clng > 40
surface[:windows].values.map { |window| window[:ref] = ref }
end
if surface.key?(:doors)
- surface[:doors].each do |i, door|
- ref = 0.9
- ref = 2.0 if door.key?(:glazed) && door[:glazed]
- ref *= 43 / (heating + 25) if heating < 18 && cooling > 40
+ surface[:doors].values.each do |door|
+ ref = ( door.key?(:glazed) && door[:glazed] ) ? 2.0 : 0.9
+ ref *= 43 / (htng + 25) if htng > -25 && htng < 18 && clng > 40
+
door[:ref] = ref
end
end
@@ -1000,7 +1002,7 @@ def ua_md(ua = {}, lang = :en)
model = "* modèle : #{ua[:file]}" if ua.key?(:file) && lang == :fr
model += " (v#{ua[:version]})" if ua.key?(:version)
report << model unless model.empty?
- report << "* TBD : v3.5.2"
+ report << "* TBD : v3.6.0"
report << "* date : #{ua[:date]}"
if lang == :en
diff --git a/lib/measures/tbd/resources/utils.rb b/lib/measures/tbd/resources/utils.rb
index 2e96f59..4857661 100644
--- a/lib/measures/tbd/resources/utils.rb
+++ b/lib/measures/tbd/resources/utils.rb
@@ -106,26 +106,28 @@ module OSut
# default inside + outside air film resistances (m2.K/W)
@@film = {
shading: 0.000, # NA
- partition: 0.150, # uninsulated wood- or steel-framed wall
- wall: 0.150, # un/insulated wall
- roof: 0.140, # un/insulated roof
- floor: 0.190, # un/insulated (exposed) floor
- basement: 0.120, # un/insulated basement wall
- slab: 0.160, # un/insulated basement slab or slab-on-grade
+ ceiling: 0.266, # interzone floor/ceiling
+ partition: 0.239, # interzone wall partition
+ wall: 0.150, # exposed wall
+ roof: 0.135, # exposed roof
+ floor: 0.192, # exposed floor
+ basement: 0.120, # basement wall
+ slab: 0.162, # basement slab or slab-on-grade
door: 0.150, # standard, 45mm insulated steel (opaque) door
window: 0.150, # vertical fenestration, e.g. glazed doors, windows
- skylight: 0.140 # e.g. domed 4' x 4' skylight
+ skylight: 0.135 # e.g. domed 4' x 4' skylight
}.freeze
# default (~1980s) envelope Uo (W/m2•K), based on surface type
@@uo = {
shading: nil, # N/A
+ ceiling: nil, # N/A
partition: nil, # N/A
wall: 0.384, # rated R14.8 hr•ft2F/Btu
roof: 0.327, # rated R17.6 hr•ft2F/Btu
floor: 0.317, # rated R17.9 hr•ft2F/Btu (exposed floor)
- basement: nil,
- slab: nil,
+ basement: nil, # N/A
+ slab: nil, # N/A
door: 1.800, # insulated, unglazed steel door (single layer)
window: 2.800, # e.g. patio doors (simple glazing)
skylight: 3.500 # all skylight technologies
@@ -197,6 +199,61 @@ module OSut
@@mats[:door ][:rho] = 600.000
@@mats[:door ][:cp ] = 1000.000
+ ##
+ # Returns surface air film resistance(s). Surface tilt-dependent values are
+ # returned if a valid surface tilt [0, PI] is provided. Otherwise, generic
+ # tilt-independent air film resistances are returned instead.
+ #
+ # @param [:to_sym] surface type, e.g. :roof, :wall, :partition, :ceiling
+ # @param [Numeric] surface tilt (in rad), optional
+ #
+ # @return [Float] surface air film resistance(s)
+ # @return [0.0] if invalid input (see logs)
+ def filmResistances(type = :wall, tilt = 2 * Math::PI)
+ mth = "OSut::#{__callee__}"
+
+ unless tilt.is_a?(Numeric)
+ return mismatch("tilt", tilt, Float, mth, DBG, 0.0)
+ end
+
+ unless type.respond_to?(:to_sym)
+ return mismatch("type", type, Symbol, mth, DBG, 0.0)
+ end
+
+ type = type.to_s.downcase.to_sym
+
+ unless @@film.key?(type)
+ return invalid("type", mth, 1, DBG, 0.0)
+ end
+
+ # Generic, tilt-independent values.
+ r = @@film[type]
+ return r if type == :shading
+
+ # Valid tilt?
+ if tilt.between?(0, Math::PI)
+ r = OpenStudio::Model::PlanarSurface.stillAirFilmResistance(tilt)
+ return r if type == :basement || type == :slab
+
+ if type == :ceiling || type == :partition
+ # Interzone. Fetch reciprocal tilt, e.g. if tilt == 0°, tiltx = 180°
+ tiltx = tilt + Math::PI
+
+ # Assuming tilt is contrained [0°, 180°] - constrain tiltx [0° 180°]:
+ # e.g. tiltx == 210° if tilt == 30°, so convert tiltx to 150°
+ # e.g. tiltx == 330° if tilt == 150°, so convert tiltx to 30°
+ # e.g. tiltx == 275° if tilt == 95°, so convert tiltx to 85°
+ tiltx = Math::PI - tilt if tiltx > Math::PI
+
+ r += OpenStudio::Model::PlanarSurface.stillAirFilmResistance(tiltx)
+ else
+ r += 0.03 # "MOVINGAIR_15MPH"
+ end
+ end
+
+ r
+ end
+
##
# Validates if every material in a layered construction is standard & opaque.
#
@@ -470,7 +527,7 @@ def assignUniqueMaterial(lc = nil, index = nil)
##
# Resets a construction's Uo factor by adjusting its insulating layer
# thermal conductivity, then if needed its thickness (or its RSi value if
- # massless). Unless material uniquness is requested, a matching material is
+ # massless). Unless material uniqueness is requested, a matching material is
# recovered instead of instantiating a new one. The latter is renamed
# according to its adjusted conductivity/thickness (or RSi value).
#
@@ -600,10 +657,6 @@ def genConstruction(model = nil, specs = {})
return mismatch("model", model, cl1, mth) unless model.is_a?(cl1)
return mismatch("specs", specs, cl2, mth) unless specs.is_a?(cl2)
- specs[:id] = "" unless specs.key?(:id)
- id = trim(specs[:id])
- id = "OSut:CON:#{specs[:type]}" if id.empty?
-
if specs.key?(:type)
unless @@uo.keys.include?(specs[:type])
return invalid("surface type", mth, 2, ERR)
@@ -612,6 +665,10 @@ def genConstruction(model = nil, specs = {})
specs[:type] = :wall
end
+ specs[:id] = "" unless specs.key?(:id)
+ id = trim(specs[:id])
+ id = "OSut:CON:#{specs[:type]}" if id.empty?
+
specs[:uo] = @@uo[ specs[:type] ] unless specs.key?(:uo) # can be nil
u = specs[:uo]
@@ -652,6 +709,35 @@ def genConstruction(model = nil, specs = {})
a[:compo][:mat] = @@mats[mt]
a[:compo][:d ] = d
a[:compo][:id ] = "OSut:#{mt}:#{format('%03d', d*1000)[-3..-1]}"
+ when :ceiling
+ unless specs[:clad] == :none
+ mt = :concrete
+ mt = :material if specs[:clad] == :light
+ d = 0.015
+ d = 0.100 if specs[:clad] == :medium
+ d = 0.200 if specs[:clad] == :heavy
+ a[:clad][:mat] = @@mats[mt]
+ a[:clad][:d ] = d
+ a[:clad][:id ] = "OSut:#{mt}:#{format('%03d', d*1000)[-3..-1]}"
+ end
+
+ mt = :mineral
+ mt = :polyiso if specs[:frame] == :medium
+ mt = :cellulose if specs[:frame] == :heavy
+ mt = :material unless u
+ d = 0.100
+ d = 0.015 unless u
+ a[:compo][:mat] = @@mats[mt]
+ a[:compo][:d ] = d
+ a[:compo][:id ] = "OSut:#{mt}:#{format('%03d', d*1000)[-3..-1]}"
+
+ unless specs[:finish] == :none
+ mt = :material
+ d = 0.015
+ a[:finish][:mat] = @@mats[mt]
+ a[:finish][:d ] = d
+ a[:finish][:id ] = "OSut:#{mt}:#{format('%03d', d*1000)[-3..-1]}"
+ end
when :partition
unless specs[:clad] == :none
d = 0.015
diff --git a/lib/tbd/geo.rb b/lib/tbd/geo.rb
index 00aeae4..905bade 100644
--- a/lib/tbd/geo.rb
+++ b/lib/tbd/geo.rb
@@ -299,14 +299,16 @@ def properties(surface = nil, argh = {})
return invalid("#{nom} normal", mth, 0, ERR) unless n
type = surface.surfaceType.downcase
- facing = surface.outsideBoundaryCondition
+ facing = surface.outsideBoundaryCondition.downcase
+ interz = false
setpts = setpoints(space)
- if facing.downcase == "surface"
- empty = surface.adjacentSurface.empty?
- return invalid("#{nom}: adjacent surface", mth, 0, ERR) if empty
+ if facing == "surface"
+ adj = surface.adjacentSurface
+ return invalid("#{nom}: adjacent surface", mth, 0, ERR) if adj.empty?
- facing = surface.adjacentSurface.get.nameString
+ facing = adj.get.nameString
+ interz = true
end
unless surface.construction.empty?
@@ -315,8 +317,9 @@ def properties(surface = nil, argh = {})
unless lc.empty?
lc = lc.get
lyr = insulatingLayer(lc)
+ idx = lyr[:index]
- if lyr[:index].is_a?(Integer) && lyr[:index].between?(0, lc.numLayers - 1)
+ if idx.is_a?(Integer) && idx.between?(0, lc.numLayers - 1)
surf[:construction] = lc
# index: ... of layer/material (to derate) within construction
# ltype: either :massless (RSi) or :standard (k + d)
@@ -358,8 +361,14 @@ def properties(surface = nil, argh = {})
surf[:story ] = story.get unless story.empty?
surf[:n ] = n
surf[:gross ] = surface.grossArea
- surf[:filmRSI ] = surface.filmResistance
surf[:spandrel ] = spandrel?(surface)
+ surf[:filmRSI ] = surface.filmResistance
+
+ if interz
+ typ = :ceiling # interzone roof or ceiling
+ typ = :partition if surf[:type] == :wall
+ surf[:filmRSI] = TBD.filmResistances(typ, surface.tilt)
+ end
surface.subSurfaces.sort_by { |s| s.nameString }.each do |s|
next if poly(s).empty?
@@ -508,7 +517,7 @@ def properties(surface = nil, argh = {})
end
unless u.is_a?(Numeric)
- r = rsi(c, surface.filmResistance)
+ r = rsi(c, surf[:filmRSI])
if r < TOL
log(ERR, "Skipping '#{id}': U-factor unavailable (#{mth})")
@@ -831,7 +840,7 @@ def kiva(model = nil, walls = {}, floors = {}, edges = {})
edge[:surfaces].keys.each do |id|
next unless floors.key?(id)
- next unless floors[id][:boundary].downcase == "foundation"
+ next unless floors[id][:boundary] == "foundation"
next if floors[id].key?(:kiva)
# Initially set as slab-on-grade. Track 'exposed foundation perimeter'.
@@ -845,7 +854,7 @@ def kiva(model = nil, walls = {}, floors = {}, edges = {})
edge[:surfaces].keys.each do |i|
next if i == id
next unless walls.key?(i)
- next unless walls[i][:boundary].downcase == "foundation"
+ next unless walls[i][:boundary] == "foundation"
next if walls[i].key?(:kiva)
floors[id][:kiva ] = :basement
@@ -857,7 +866,7 @@ def kiva(model = nil, walls = {}, floors = {}, edges = {})
edge[:surfaces].keys.each do |i|
next if i == id
next unless walls.key?(i)
- next unless walls[i][:boundary].downcase == "outdoors"
+ next unless walls[i][:boundary] == "outdoors"
floors[id][:exposed] += edge[:length]
end
@@ -872,7 +881,7 @@ def kiva(model = nil, walls = {}, floors = {}, edges = {})
e[:surfaces].keys.each do |ii|
next if i == ii
next unless walls.key?(ii)
- next unless walls[ii][:boundary].downcase == "foundation"
+ next unless walls[ii][:boundary] == "foundation"
next if walls[ii].key?(:kiva)
floors[id][:kiva ] = :basement
@@ -883,7 +892,7 @@ def kiva(model = nil, walls = {}, floors = {}, edges = {})
e[:surfaces].keys.each do |ii|
next if i == ii
next unless walls.key?(ii)
- next unless walls[ii][:boundary].downcase == "outdoors"
+ next unless walls[ii][:boundary] == "outdoors"
floors[id][:exposed] += e[:length]
end
diff --git a/lib/tbd/psi.rb b/lib/tbd/psi.rb
index 7c37714..dba602b 100644
--- a/lib/tbd/psi.rb
+++ b/lib/tbd/psi.rb
@@ -1424,8 +1424,10 @@ def derate(id = "", s = {}, lc = nil)
m = m.clone(model).to_MasslessOpaqueMaterial.get
m.setName("#{id} #{up}m tbd")
- de_r = RMIN unless de_r > RMIN
- loss = (de_u - 1 / de_r) * net unless de_r > RMIN
+ if de_r < RMIN
+ de_r = RMIN
+ loss = (de_u - 1 / de_r) * net
+ end
unless m.setThermalResistance(de_r)
return invalid("Can't derate #{id}: RSi#{de_r.round(2)}", mth)
@@ -1555,7 +1557,7 @@ def process(model = nil, argh = {})
next unless surface[:conditioned]
next if surface[:ground ]
- unless surface[:boundary].downcase == "outdoors"
+ unless surface[:boundary] == "outdoors"
next unless tbd[:surfaces].key?(surface[:boundary])
next if tbd[:surfaces][surface[:boundary]][:conditioned]
end
@@ -2202,7 +2204,7 @@ def process(model = nil, argh = {})
next if holes.key?(i)
next if shades.key?(i)
- facing = tbd[:surfaces][i][:boundary].downcase
+ facing = tbd[:surfaces][i][:boundary]
next unless facing == "othersidecoefficients"
s1 = edge[:surfaces][id]
@@ -2971,6 +2973,7 @@ def process(model = nil, argh = {})
# derate a construction/material pair having " tbd" in their OpenStudio name.
tbd[:surfaces].each do |id, surface|
next unless surface.key?(:construction)
+ next unless surface.key?(:filmRSI)
next unless surface.key?(:index)
next unless surface.key?(:ltype)
next unless surface.key?(:r)
@@ -2981,8 +2984,7 @@ def process(model = nil, argh = {})
s = model.getSurfaceByName(id)
next if s.empty?
- s = s.get
-
+ s = s.get
index = surface[:index ]
current_c = surface[:construction]
c = current_c.clone(model).to_LayeredConstruction.get
@@ -2995,7 +2997,7 @@ def process(model = nil, argh = {})
if m
c.setLayer(index, m)
c.setName("#{id} c tbd")
- current_R = rsi(current_c, s.filmResistance)
+ current_R = rsi(current_c, surface[:filmRSI])
# In principle, the derated "ratio" could be calculated simply by
# accessing a surface's uFactor. Yet air layers within constructions
@@ -3035,7 +3037,7 @@ def process(model = nil, argh = {})
# Compute updated RSi value from layers.
updated_c = s.construction.get.to_LayeredConstruction.get
- updated_R = rsi(updated_c, s.filmResistance)
+ updated_R = rsi(updated_c, surface[:filmRSI])
ratio = -(current_R - updated_R) * 100 / current_R
surface[:ratio] = ratio if ratio.abs > TOL
@@ -3047,14 +3049,10 @@ def process(model = nil, argh = {})
tbd[:surfaces].each do |id, surface|
next unless surface[:deratable]
next unless surface.key?(:construction)
+ next unless surface.key?(:filmRSI)
next if surface.key?(:u)
- s = model.getSurfaceByName(id)
- msg = "Skipping missing surface '#{id}' (#{mth})"
- log(ERR, msg) if s.empty?
- next if s.empty?
-
- surface[:u] = 1.0 / rsi(surface[:construction], s.get.filmResistance)
+ surface[:u] = 1.0 / rsi(surface[:construction], surface[:filmRSI])
end
json[:io][:edges] = []
@@ -3204,8 +3202,8 @@ def exit(runner = nil, argh = {})
uo = format("%.3f", g[:uo])
ut = format("%.3f", g[:ut])
- output = "An initial #{label.to_s} Uo of #{uo} W/m2•K is required to " \
- "achieve an overall Ut of #{ut} W/m2•K for #{g[:op]}"
+ output = "An area-weighted #{label.to_s} Uo of #{uo} W/m2•K is " \
+ "required to meet an overall Ut of #{ut} W/m2•K for #{g[:op]}"
u_t << output
runner.registerInfo(output)
end
diff --git a/lib/tbd/ua.rb b/lib/tbd/ua.rb
index c4caad3..994d0b5 100644
--- a/lib/tbd/ua.rb
+++ b/lib/tbd/ua.rb
@@ -25,103 +25,113 @@ module TBD
# Calculates construction Uo (including surface film resistances) to meet Ut.
#
# @param model [OpenStudio::Model::Model] a model
- # @param lc [OpenStudio::Model::LayeredConstruction] a layered construction
# @param id [#to_s] layered construction identifier
- # @param hloss [Numeric] heat loss from major thermal bridging, in W/K
+ # @param lc [OpenStudio::Model::LayeredConstruction] a layered construction
+ # @param area [Numeric] net surface area covered by layered construction
# @param film [Numeric] target surface film resistance, in m2•K/W
+ # @param hloss [Numeric] heat loss from major thermal bridging, in W/K
# @param ut [Numeric] target overall Ut for lc, in W/m2•K
#
- # @return [Hash] uo: lc Uo [W/m2•K] to meet Ut, m: uprated lc layer
- # @return [Hash] uo: (nil), m: (nil) if invalid input (see logs)
- def uo(model = nil, lc = nil, id = "", hloss = 0.0, film = 0.0, ut = 0.0)
+ # @return [Float] Uo [W/m2•K] required to meet Ut (see logs if 0 or UMIN)
+ def uo(id = "", lc = nil, area = 0, film = 0, hloss = 0, ut = 0)
mth = "TBD::#{__callee__}"
- res = { uo: nil, m: nil }
- cl1 = OpenStudio::Model::Model
- cl2 = OpenStudio::Model::LayeredConstruction
- cl3 = Numeric
- cl4 = String
+ cl1 = OpenStudio::Model::LayeredConstruction
+ cl2 = Numeric
+ cl3 = String
id = trim(id)
- return mismatch("model", model, cl1, mth, DBG, res) unless model.is_a?(cl1)
- return mismatch("id" , id, cl4, mth, DBG, res) if id.empty?
- return mismatch("lc" , lc, cl2, mth, DBG, res) unless lc.is_a?(cl2)
- return mismatch("hloss", hloss, cl3, mth, DBG, res) unless hloss.is_a?(cl3)
- return mismatch("film" , film, cl3, mth, DBG, res) unless film.is_a?(cl3)
- return mismatch("Ut" , ut, cl3, mth, DBG, res) unless ut.is_a?(cl3)
-
- loss = 0.0 # residual heatloss (not assigned) [W/K]
- area = lc.getNetArea
- lyr = insulatingLayer(lc)
+ return mismatch("id" , id, cl3, mth, DBG, 0) if id.empty?
+ return mismatch("lc" , lc, cl1, mth, DBG, 0) unless lc.is_a?(cl1)
+ return mismatch("area" , area, cl2, mth, DBG, 0) unless area.is_a?(cl2)
+ return mismatch("film" , film, cl2, mth, DBG, 0) unless film.is_a?(cl2)
+ return mismatch("hloss", hloss, cl2, mth, DBG, 0) unless hloss.is_a?(cl2)
+ return mismatch("Ut" , ut, cl2, mth, DBG, 0) unless ut.is_a?(cl2)
+
+ # Residual heatloss (not assigned) [W/K].
+ model = lc.model
+ loss = 0
+ lyr = insulatingLayer(lc)
+
+ # Validate insulating layer.
lyr[:index] = nil unless lyr[:index].is_a?(Numeric)
lyr[:index] = nil unless lyr[:index] >= 0
lyr[:index] = nil unless lyr[:index] < lc.layers.size
- return invalid("#{id} layer index", mth, 3, WRN, res) unless lyr[:index]
- return zero("#{id}: heatloss" , mth, WRN, res) unless hloss > TOL
- return zero("#{id}: films" , mth, WRN, res) unless film > TOL
- return zero("#{id}: Ut" , mth, WRN, res) unless ut > UMIN
- return invalid("#{id}: Ut" , mth, 6, WRN, res) unless ut < UMAX
- return zero("#{id}: net area (m2)", mth, WRN, res) unless area > TOL
-
- # First, calculate initial layer RSi to initially meet Ut target.
- rt = 1 / ut # target construction Rt
- ro = rsi(lc, film) # current construction Ro
- new_r = lyr[:r] + (rt - ro) # new, un-derated layer RSi
- new_u = 1 / new_r
-
- # Then, uprate (if possible) to counter expected thermal bridging effects.
- u_psi = hloss / area # from psi+khi
- new_u -= u_psi # uprated layer USi to counter psi+khi
- new_r = 1 / new_u # uprated layer RSi to counter psi+khi
- return zero("#{id}: new Rsi", mth, WRN, res) unless new_r > RMIN
+ return invalid("#{id} layer index", mth, 3, DBG, 0) unless lyr[:index]
+ return zero("#{id}: net area (m2)", mth, DBG, 0) unless area > TOL
+ return negative("#{id}: film RSI" , mth, DBG, 0) if film < 0
+ return zero("#{id}: heatloss" , mth, DBG, 0) if hloss < TOL
+ return zero("#{id}: Ut" , mth, DBG, 0) unless ut > UMIN
+ return invalid("#{id}: Ut" , mth, 4, DBG, 0) unless ut < UMAX
+
+ # Calculate initial layer RSi to initially meet Ut target.
+ rt = 1 / ut # target construction Rt
+ r0 = rsi(lc, film) # current construction R0
+ r = lyr[:r] + rt - r0 # new, un-derated layer RSi
+
+ # Adjust if below admissible threshold.
+ if r < 0
+ zero("#{id}: layer RSI", mth, INF)
+ r = RMIN
+ end
+
+ # Uprate to counter heat loss from thermal bridging.
+ u = 1 / r
+ u -= (hloss / area)
+
+ # Adjust if beyond admissible range.
+ if u < UMIN
+ negative("#{id}: new Uo", mth, INF)
+ u = UMIN
+ end
+
+ r = 1 / u
if lyr[:type] == :massless
m = lc.getLayer(lyr[:index]).to_MasslessOpaqueMaterial
- return invalid("#{id} massless layer?", mth, 0, DBG, res) if m.empty?
+ return invalid("#{id} massless layer?", mth, 0, DBG, 0) if m.empty?
m = m.get.clone(model).to_MasslessOpaqueMaterial.get
m.setName("#{id} uprated")
- new_r = RMIN unless new_r > RMIN
- loss = (new_u - 1 / new_r) * area unless new_r > RMIN
+ if r < RMIN
+ r = RMIN
+ loss = (u - 1 / r) * area
+ end
- unless m.setThermalResistance(new_r)
- return invalid("Can't uprate #{id}: RSi#{new_r.round(2)}", mth, 0, DBG, res)
+ unless m.setThermalResistance(r)
+ return invalid("Can't uprate #{id}: RSi#{r.round(2)}", mth, 0, DBG, 0)
end
else
m = lc.getLayer(lyr[:index]).to_StandardOpaqueMaterial
- return invalid("#{id} standard layer?", mth, 0, DBG, res) if m.empty?
+ return invalid("#{id} standard layer?", mth, 0, DBG, 0) if m.empty?
m = m.get.clone(model).to_StandardOpaqueMaterial.get
m.setName("#{id} uprated")
d = m.thickness
- k = (d / new_r).clamp(KMIN, KMAX)
- d = (k * new_r).clamp(DMIN, DMAX)
+ k = (d / r).clamp(KMIN, KMAX)
+ d = (k * r).clamp(DMIN, DMAX)
- loss = (new_u - k / d) * area unless d / k > RMIN
+ loss = (u - k / d) * area if d / k < RMIN
unless m.setThermalConductivity(k)
- return invalid("Can't uprate #{id}: K#{k.round(3)}", mth, 0, DBG, res)
+ return invalid("Can't uprate #{id}: K#{k.round(3)}", mth, 0, DBG, 0)
end
unless m.setThickness(d)
- return invalid("Can't uprate #{id}: #{(d*1000).to_i}mm", mth, 0, DBG, res)
+ return invalid("Can't uprate #{id}: #{(d*1000).to_i}mm", mth, 0, DBG, 0)
end
end
- return invalid("Can't ID insulating layer", mth, 0, DBG, res) unless m
+ return invalid("Can't ID insulating layer", mth, 0, DBG, 0) unless m
lc.setLayer(lyr[:index], m)
- uo = 1 / rsi(lc, film)
-
- if loss > TOL
- h_loss = format "%.3f", loss
- return invalid("Can't assign #{h_loss} W/K to #{id}", mth, 0, DBG, res)
- end
+ ro = rsi(lc, film)
+ uo = ro < RMIN ? UMIN : 1 / ro
- res[:uo] = uo
- res[:m ] = m
+ h = format "%.3f", loss
+ log(INF, "Can't set #{h} W/K to #{id} #{mth}") if loss > TOL
- res
+ uo
end
##
@@ -185,230 +195,219 @@ def uprate(model = nil, s = {}, argh = {})
groups[:roof ][:ut] = argh[:roof_ut ]
groups[:floor][:ut] = argh[:floor_ut ]
- groups[:wall ][:op] = trim(argh[:wall_option ])
- groups[:roof ][:op] = trim(argh[:roof_option ])
- groups[:floor][:op] = trim(argh[:floor_option ])
+ groups[:wall ][:op] = trim(argh[:wall_option ])
+ groups[:roof ][:op] = trim(argh[:roof_option ])
+ groups[:floor][:op] = trim(argh[:floor_option])
+ # Group and process walls, roofs and floors sequentially/independently.
groups.each do |type, g|
next unless g[:up]
next unless g[:ut].is_a?(Numeric)
next unless g[:ut] < UMAX
- next if g[:ut] < 0
+ next unless g[:ut] > UMIN
- typ = type
- typ = :ceiling if typ == :roof
+ typ = type
+ typ = :ceiling if typ == :roof
+
+ # Collection of one or several constructions to uprate.
coll = {}
- area = 0
- film = 100000000000000
- lc = nil
- id = ""
- op = g[:op].downcase
- all = tout.include?(op)
-
- if g[:op].empty?
- log(WRN, "Construction (#{type}) to uprate? (#{mth})")
- elsif all
+ op = g[:op]
+
+ # Uprate ALL constructions of same type, e.g. walls.
+ if tout.include?(op.downcase)
s.each do |nom, surface|
- next unless surface.key?(:deratable )
- next unless surface.key?(:type )
+ next unless surface.key?(:deratable)
+ next unless surface.key?(:type)
next unless surface.key?(:construction)
- next unless surface.key?(:filmRSI )
- next unless surface.key?(:index )
- next unless surface.key?(:ltype )
- next unless surface.key?(:r )
+ next unless surface.key?(:filmRSI)
+ next unless surface.key?(:ltype)
+ next unless surface.key?(:r)
+ next unless surface.key?(:index)
+ next unless surface.key?(:net)
next unless surface[:deratable ]
next unless surface[:type ] == typ
next unless surface[:construction].is_a?(cl3)
next if surface[:index ].nil?
- # Retain lowest surface film resistance (e.g. tilted surfaces).
- c = surface[:construction]
- i = c.nameString
- aire = c.getNetArea
- film = surface[:filmRSI] if surface[:filmRSI] < film
-
- # Retain construction covering largest area. The following conditional
- # is reliable UNLESS linked to other deratable surface types e.g. both
- # floors AND walls (see "elsif lc" corrections below).
- if aire > area
- lc = c
- area = aire
- id = i
+ # Collect constructions to uprate.
+ lc = surface[:construction]
+ id = lc.nameString
+
+ # Track construction-specific parameters.
+ unless coll.key?(id)
+ coll[id] = {}
+ coll[id][:lc ] = lc
+ coll[id][:s ] = {}
+ coll[id][:hloss] = 0
+ coll[id][:area ] = 0
+ coll[id][:film ] = 0
+ coll[id][:fA ] = 0
+ coll[id][:uA ] = 0
+ coll[id][:u0 ] = 0
end
- coll[i] = { area: aire, lc: c, s: {} } unless coll.key?(i)
- coll[i][:s][nom] = { a: surface[:net] } unless coll[i][:s].key?(nom)
+ coll[id][:idx] = surface[:index] unless coll[id].key?(:idx)
+ coll[id][:ltp] = surface[:ltype] unless coll[id].key?(:ltp)
+
+ # Track surface-specific parameters.
+ unless coll[id][:s].key?(nom)
+ coll[id][:s][nom] = {}
+ coll[id][:s][nom][:a] = surface[:net]
+ coll[id][:s][nom][:f] = surface[:filmRSI]
+ coll[id][:s][nom][:h] = 0
+ next unless surface.key?(:heatloss)
+ next unless surface[:heatloss].abs > TOL
+
+ coll[id][:s][nom][:h] = surface[:heatloss]
+ end
end
else
- id = g[:op]
+ id = op # single, user-selected construction
lc = model.getConstructionByName(id)
- log(WRN, "Construction '#{id}'? (#{mth})") if lc.empty?
- next if lc.empty?
+
+ if lc.empty?
+ log(WRN, "Construction '#{id}'? (#{mth})")
+ next
+ end
lc = lc.get.to_LayeredConstruction
- log(WRN, "'#{id}' layered construction? (#{mth})") if lc.empty?
- next if lc.empty?
- lc = lc.get
- area = lc.getNetArea
- coll[id] = { area: area, lc: lc, s: {} }
+ if lc.empty?
+ log(WRN, "'#{id}' layered construction? (#{mth})")
+ next
+ end
+
+ lc = lc.get
+
+ coll[id] = {}
+ coll[id][:lc ] = lc
+ coll[id][:s ] = {}
+ coll[id][:hloss] = 0
+ coll[id][:area ] = 0
+ coll[id][:film ] = 0
+ coll[id][:fA ] = 0
+ coll[id][:uA ] = 0
+ coll[id][:u0 ] = 0
s.each do |nom, surface|
- next unless surface.key?(:deratable )
- next unless surface.key?(:type )
+ next unless surface.key?(:deratable)
+ next unless surface.key?(:type)
next unless surface.key?(:construction)
- next unless surface.key?(:filmRSI )
- next unless surface.key?(:index )
- next unless surface.key?(:ltype )
- next unless surface.key?(:r )
+ next unless surface.key?(:filmRSI)
+ next unless surface.key?(:ltype)
+ next unless surface.key?(:r)
+ next unless surface.key?(:index)
+ next unless surface.key?(:net)
next unless surface[:deratable ]
next unless surface[:type ] == typ
next unless surface[:construction].is_a?(cl3)
+ next unless surface[:construction].nameString == id
next if surface[:index ].nil?
- i = surface[:construction].nameString
- next unless i == id
-
- # Retain lowest surface film resistance (e.g. tilted surfaces).
- film = surface[:filmRSI] if surface[:filmRSI] < film
-
- coll[i][:s][nom] = { a: surface[:net] } unless coll[i][:s].key?(nom)
+ coll[id][:idx] = surface[:index] unless coll[id].key?(:idx)
+ coll[id][:ltp] = surface[:ltype] unless coll[id].key?(:ltp)
+
+ # Track (for surfaces of targeted type):
+ # - net area
+ # - air film resistances
+ unless coll[id][:s].key?(nom)
+ coll[id][:s][nom] = {}
+ coll[id][:s][nom][:a] = surface[:net]
+ coll[id][:s][nom][:f] = surface[:filmRSI]
+ coll[id][:s][nom][:h] = 0
+ next unless surface.key?(:heatloss)
+ next unless surface[:heatloss].abs > TOL
+
+ coll[id][:s][nom][:h] = surface[:heatloss]
+ end
end
end
if coll.empty?
- log(WRN, "No #{type} construction to uprate - skipping (#{mth})")
+ log(WRN, "Unable to uprate #{type} construction - skipping (#{mth})")
next
- elsif lc
- # Valid layered construction - good to uprate!
- lyr = insulatingLayer(lc)
- lyr[:index] = nil unless lyr[:index].is_a?(Numeric)
- lyr[:index] = nil unless lyr[:index] >= 0
- lyr[:index] = nil unless lyr[:index] < lc.layers.size
-
- log(WRN, "Insulation index for '#{id}'? (#{mth})") unless lyr[:index]
- next unless lyr[:index]
-
- # Ensure lc is exclusively linked to deratable surfaces of right type.
- # If not, assign new lc clone to non-targeted surfaces.
- s.each do |nom, surface|
- next unless surface.key?(:type )
- next unless surface.key?(:deratable )
- next unless surface.key?(:construction)
- next unless surface[:construction].is_a?(cl3)
- next unless surface[:construction] == lc
- next unless surface[:deratable]
-
- ok = true
- ok = false unless surface[:type] == typ
- ok = false unless coll.key?(id)
- ok = false unless coll[id][:s].key?(nom)
-
- unless ok
- log(WRN, "Cloning '#{nom}' construction - not '#{id}' (#{mth})")
- sss = model.getSurfaceByName(nom)
- next if sss.empty?
-
- sss = sss.get
+ else
+ coll.each do |id, col|
+ lc = col[:lc]
+
+ # Ensure lc is exclusively linked to deratable surfaces of targeted
+ # type. If not, assign new lc clone to non-targeted surfaces.
+ s.each do |nom, surface|
+ next unless surface.key?(:deratable)
+ next unless surface.key?(:type)
+ next unless surface.key?(:construction)
+ next unless surface.key?(:filmRSI)
+ next unless surface.key?(:ltype)
+ next unless surface.key?(:r)
+ next unless surface.key?(:index)
+ next unless surface.key?(:net)
+ next unless surface[:deratable]
+ next unless surface[:construction].is_a?(cl3)
+ next unless surface[:construction] == lc
+ next if surface[:index ].nil?
+ next if surface[:type ] == typ
+ next if coll[id][:s].key?(nom)
+
+ log(INF, "Cloning '#{nom}' construction - not '#{id}' (#{mth})")
+ srf = model.getSurfaceByName(nom)
+ next if srf.empty?
+
+ srf = srf.get
cloned = lc.clone(model).to_LayeredConstruction.get
cloned.setName("#{nom} - cloned")
- sss.setConstruction(cloned)
+ srf.setConstruction(cloned)
surface[:construction] = cloned
- coll[id][:s].delete(nom)
end
end
- hloss = 0 # sum of applicable psi+khi-related losses [W/K]
-
- # Tally applicable psi+khi losses. Possible construction reassignment.
- coll.each do |i, col|
- col[:s].keys.each do |nom|
- next unless s.key?(nom)
- next unless s[nom].key?(:construction)
- next unless s[nom].key?(:index)
- next unless s[nom].key?(:ltype)
- next unless s[nom].key?(:r)
-
- # Tally applicable psi+khi.
- hloss += s[nom][:heatloss ] if s[nom].key?(:heatloss)
- next if s[nom][:construction] == lc
-
- # Reassign construction unless referencing lc.
- sss = model.getSurfaceByName(nom)
- next if sss.empty?
-
- sss = sss.get
-
- if sss.isConstructionDefaulted
- set = defaultConstructionSet(sss) # building? story?
-
- if set.nil?
- sss.setConstruction(lc)
- else
- constructions = set.defaultExteriorSurfaceConstructions
-
- unless constructions.empty?
- constructions = constructions.get
- constructions.setWallConstruction(lc) if typ == :wall
- constructions.setFloorConstruction(lc) if typ == :floor
- constructions.setRoofCeilingConstruction(lc) if typ == :ceiling
- end
- end
- else
- sss.setConstruction(lc)
- end
+ coll.each do |id, col|
+ col[:s].values.each do |item|
+ col[:hloss] += item[:h]
+ col[:area ] += item[:a]
+ col[:fA ] += item[:a] / item[:f] unless item[:f] < 0
+ end
- s[nom][:construction] = lc # reset TBD attributes
- s[nom][:index ] = lyr[:index]
- s[nom][:ltype ] = lyr[:type ]
- s[nom][:r ] = lyr[:r ] # temporary
+ if col[:area] < TOL
+ empty("#{id} area", mth, WRN)
+ next
end
- end
- # Merge to ensure a single entry for coll Hash.
- coll.each do |i, col|
- next if i == id
+ # Area-weighted surface air film resistances.
+ col[:film] = 1 / (col[:fA] / col[:area])
- col[:s].each do |nom, sss|
- coll[id][:s][nom] = sss unless coll[id][:s].key?(nom)
+ # Fetch required, uprated Uo.
+ u = uo(id, col[:lc], col[:area], col[:film], col[:hloss], g[:ut])
+
+ unless u > UMIN
+ log(WRN, "Unable to completely uprate '#{id}' (#{mth})")
+ u = UMIN
end
- end
- coll.delete_if { |i, _| i != id }
+ col[:u ] = u
+ col[:uA] = u * col[:area]
- unless coll.size == 1
- log(DBG, "Collection == 1? for '#{id}' (#{mth})")
- next
- end
+ # Recoup uprated construction and insulating layer.
+ lc = col[:lc]
+ lyr = insulatingLayer(lc)
- coll[id][:area] = lc.getNetArea
- res = uo(model, lc, id, hloss, film, g[:ut])
+ # Reset surface :r (uprated RSi of insulation, before derating).
+ col[:s].keys.each do |nom|
+ next unless s.key?(nom)
+ next unless s[nom].key?(:r)
- unless res[:uo] && res[:m]
- log(WRN, "Unable to uprate '#{id}' (#{mth})")
- next
+ s[nom][:r] = lyr[:r]
+ end
end
- lyr = insulatingLayer(lc)
-
- # Loop through coll :s, and reset :r - likely modified by uo().
- coll.values.first[:s].keys.each do |nom|
- next unless s.key?(nom)
- next unless s[nom].key?(:index)
- next unless s[nom].key?(:ltype)
- next unless s[nom].key?(:r )
- next unless s[nom][:index] == lyr[:index]
- next unless s[nom][:ltype] == lyr[:type ]
+ # Store UA-averaged, upgraded Uo-factor per type.
+ area = coll.values.sum { |col| col[:area] }
+ uA = coll.values.sum { |col| col[:uA ] }
- s[nom][:r] = lyr[:r] # uprated insulating RSi factor, before derating
+ if area > TOL
+ argh[:wall_uo ] = uA / area if typ == :wall
+ argh[:roof_uo ] = uA / area if typ == :ceiling
+ argh[:floor_uo] = uA / area if typ == :floor
end
-
- argh[:wall_uo ] = res[:uo] if typ == :wall
- argh[:roof_uo ] = res[:uo] if typ == :ceiling
- argh[:floor_uo] = res[:uo] if typ == :floor
- else
- log(WRN, "Nilled construction to uprate - (#{mth})")
- return false
end
end
@@ -442,54 +441,57 @@ def qc33(s = {}, sets = nil, spts = true)
return mismatch("sets", sets, cl1, mth, DBG, false) unless sets.is_a?(cl2)
shorts = sets.shorthands("code (Quebec)")
- empty = shorts[:has].empty? || shorts[:val].empty?
- log(DBG, "Missing QC PSI set for 3.3 UA' tradeoff (#{mth})") if empty
- return false if empty
- ok = [true, false].include?(spts)
- log(DBG, "setpoints must be true or false for 3.3 UA' tradeoff") unless ok
- return false unless ok
+ if shorts[:has].empty? || shorts[:val].empty?
+ log(DBG, "Missing QC PSI set for 3.3 UA' tradeoff (#{mth})")
+ return false
+ end
+
+ unless [true, false].include?(spts)
+ log(DBG, "setpoints must be true or false for 3.3 UA' tradeoff")
+ return false
+ end
s.each do |id, surface|
next unless surface.key?(:deratable)
next unless surface[:deratable]
next unless surface.key?(:type)
- heating = -50 if spts
- cooling = 50 if spts
- heating = 21 unless spts
- cooling = 24 unless spts
- heating = surface[:heating] if surface.key?(:heating)
- cooling = surface[:cooling] if surface.key?(:cooling)
+ htng = spts ? -24 : 21
+ clng = spts ? 50 : 24
+ htng = surface[:heating] if surface.key?(:heating)
+ clng = surface[:cooling] if surface.key?(:cooling)
- # Start with surface U-factors.
- ref = 1 / 5.46
- ref = 1 / 3.60 if surface[:type] == :wall
+ # Avoid 'divide by zero' case.
+ htng = -24 if htng < -24
- # Adjust for lower heating setpoint (assumes -25C design conditions).
- ref *= 43 / (heating + 25) if heating < 18 && cooling > 40
+ # Start with surface U-factors. Adjust for lower heating setpoint.
+ # Assumes -25C design conditions.
+ ref = ( surface[:type] == :wall ) ? (1 / 3.60) : (1 / 5.46)
+ ref *= 43 / (htng + 25) if htng > -25 && htng < 18 && clng > 40
surface[:ref] = ref
- if surface.key?(:skylights) # loop through subsurfaces
- ref = 2.85
- ref *= 43 / (heating + 25) if heating < 18 && cooling > 40
+ # Loop through subsurfaces.
+ if surface.key?(:skylights)
+ ref = 2.85
+ ref *= 43 / (htng + 25) if htng > -25 && htng < 18 && clng > 40
surface[:skylights].values.map { |skylight| skylight[:ref] = ref }
end
if surface.key?(:windows)
- ref = 2.0
- ref *= 43 / (heating + 25) if heating < 18 && cooling > 40
+ ref = 2.0
+ ref *= 43 / (htng + 25) if htng > -25 && htng < 18 && clng > 40
surface[:windows].values.map { |window| window[:ref] = ref }
end
if surface.key?(:doors)
- surface[:doors].each do |i, door|
- ref = 0.9
- ref = 2.0 if door.key?(:glazed) && door[:glazed]
- ref *= 43 / (heating + 25) if heating < 18 && cooling > 40
+ surface[:doors].values.each do |door|
+ ref = ( door.key?(:glazed) && door[:glazed] ) ? 2.0 : 0.9
+ ref *= 43 / (htng + 25) if htng > -25 && htng < 18 && clng > 40
+
door[:ref] = ref
end
end
@@ -1000,7 +1002,7 @@ def ua_md(ua = {}, lang = :en)
model = "* modèle : #{ua[:file]}" if ua.key?(:file) && lang == :fr
model += " (v#{ua[:version]})" if ua.key?(:version)
report << model unless model.empty?
- report << "* TBD : v3.5.2"
+ report << "* TBD : v3.6.0"
report << "* date : #{ua[:date]}"
if lang == :en
diff --git a/lib/tbd/version.rb b/lib/tbd/version.rb
index cd125f1..3f9ebb2 100644
--- a/lib/tbd/version.rb
+++ b/lib/tbd/version.rb
@@ -21,5 +21,5 @@
# SOFTWARE.
module TBD
- VERSION = "3.5.2".freeze
+ VERSION = "3.6.0".freeze
end
diff --git a/spec/tbd_tests_spec.rb b/spec/tbd_tests_spec.rb
index b6fae36..4b04c20 100644
--- a/spec/tbd_tests_spec.rb
+++ b/spec/tbd_tests_spec.rb
@@ -1,5 +1,4 @@
require "tbd"
-require "fileutils"
RSpec.describe TBD do
TOL = TBD::TOL.dup
@@ -153,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")
@@ -207,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!
@@ -336,7 +373,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
@@ -849,7 +886,7 @@
# "code"). So far so good. However, when "(non thermal bridging)" is
# retained as a default PSI design set (not as a reference set), all edge
# types will necessarily have PSI-factors of 0 W/K per metre. To minimize
- # the issue, slight variations (e.g. +/- 0.000001 W/K per inear meter) have
+ # the issue, slight variations (e.g. +/- 0.000001 W/K per linear meter) have
# been added to TBD built-in PSI-factor sets (where required). Without this
# fix, undesirable variations in reference UA' tallies may occur.
#
@@ -1610,14 +1647,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)
@@ -1680,8 +1716,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.:
@@ -1700,7 +1735,8 @@
#
# Uo = 0.277 - ( ∑psi • L )/A
#
- # The method exits with an ERROR in 2x cases:
+ # If impossible to meet the requested Ut, TBD hardsets Uo to UMIN while
+ # raising warnings, namely when:
# - calculated Uo is negative, i.e. ( ∑psi • L )/A > 0.277
# - calculated layer r violates E+ material constraints, e.g.
# - too conductive
@@ -1731,14 +1767,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(UMIN) # RSi 100.00 (R568)
+ 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).
@@ -1759,52 +1800,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.086) # RSi 11.63 (R66)
+ expect(argh[:roof_uo].round(3)).to eq(0.129) # RSi 7.75 (R44)
+
+ uA = 0
+ m2 = 0
model.getSurfaces.each do |s|
+ id = s.nameString
next unless s.surfaceType == "Wall"
next unless s.outsideBoundaryCondition == "Outdoors"
- walls << s.nameString
+ walls << id
+
+ expect(s.isConstructionDefaulted).to be false
c = s.construction
expect(c).to_not be_empty
c = c.get.to_LayeredConstruction
expect(c).to_not be_empty
c = c.get
-
expect(c.nameString).to include(" c tbd")
expect(c.layers.size).to eq(4)
+ r = TBD.rsi(c, TBD.filmResistances(:wall))
+ expect(r.round(3)).to eq(4.805) if id == "Surface 20" || id == "Surface 8"
+ expect(r.round(3)).to eq(4.679) if id == "Surface 14" || id == "Surface 2"
+ m2 += s.netArea
+ uA += s.netArea / r
+
insul = c.layers[2].to_StandardOpaqueMaterial
expect(insul).to_not be_empty
insul = insul.get
expect(insul.nameString).to include(" uprated m tbd")
- k1 = (insul.thermalConductivity - 0.0261).round(4) == 0
- k2 = (insul.thermalConductivity - 0.0253).round(4) == 0
- expect(k1 || k2).to be true
- expect(insul.thickness).to be_within(0.0001).of(0.1120)
+ k = insul.thermalConductivity
+ expect(k.round(3)).to eq(0.025) if id == "Surface 20" || id == "Surface 8"
+ expect(k.round(3)).to eq(0.026) if id == "Surface 14" || id == "Surface 2"
+ expect(insul.thickness.round(4)).to eq(0.1120)
end
+ expect(m2.round(2)).to eq(273.60)
+ expect(uA.round(2)).to eq(57.45)
+
+ # Reach NECB required Ut for walls?
+ ut = uA / m2
+ expect(ut.round(3)).to eq(argh[:wall_ut].round(3)) # 0.210
+
walls.each do |wall|
expect(surfaces).to have_key(wall)
expect(surfaces[wall]).to have_key(:r) # uprated, non-derated layer Rsi
expect(surfaces[wall]).to have_key(:u) # uprated, non-derated assembly
- expect(surfaces[wall][:r]).to be_within(0.001).of(11.205) # R64
- expect(surfaces[wall][:u]).to be_within(0.001).of( 0.086) # R66
+ expect(surfaces[wall][:r].round(3)).to eq(11.205) # R64
+ expect(surfaces[wall][:u].round(3)).to eq( 0.086) # R66
end
# -- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- -- #
@@ -1863,7 +1922,14 @@
argh[:wall_ut ] = 0.210 # NECB CZ7 2017 (RSi 4.76 / R41)
TBD.process(model, argh)
+ expect(TBD.warn?).to be true
+ expect(TBD.logs.size).to eq(2)
+ expect(TBD.logs.first[:message]).to include("Negative ")
+ expect(TBD.logs.first[:message]).to include(" new Uo' (TBD::uo)")
+ expect(TBD.logs.last[:message]).to include("Unable to completely uprate ")
+
expect(argh).to_not have_key(:roof_uo)
+ expect(argh).to have_key(:wall_uo)
# OpenStudio prior to v3.5.X had a 3m maximum layer thickness, reflecting a
# previous v8.8 EnergyPlus constraint. TBD caught such cases when uprating
@@ -1876,14 +1942,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
@@ -1891,7 +1958,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)
@@ -1904,12 +1971,195 @@
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)
+
+ # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- #
+ # 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
@@ -2068,8 +2318,7 @@
expect(TBD.status).to be_zero
argh = { option: "code (Quebec)" }
-
- json = TBD.process(model, argh)
+ json = TBD.process(model, argh)
expect(TBD.status).to be_zero
expect(json).to be_a(Hash)
expect(json).to have_key(:io)
@@ -2112,8 +2361,7 @@
expect(TBD.status).to be_zero
argh = { option: "code (Quebec)" }
-
- json = TBD.process(model, argh)
+ json = TBD.process(model, argh)
expect(json).to be_a(Hash)
expect(json).to have_key(:io)
expect(json).to have_key(:surfaces)
@@ -2174,7 +2422,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)
@@ -2200,7 +2447,6 @@
gra = TBD.grossRoofArea(model.getSpaces)
tm2 = srr * gra
rm2 = TBD.addSkyLights(model.getSpaces, {area: tm2})
- puts TBD.logs unless TBD.logs.empty?
expect(TBD.status).to be_zero
expect(rm2.round(2)).to eq(gra.round(2))
@@ -2213,7 +2459,14 @@
argh[:wall_ut ] = 0.215 # NECB 2020 CZ7A (RSi 4.65 / R26)
argh[:roof_ut ] = 0.121 # NECB 2020 CZ7A (RSi 8.26 / R47)
json = TBD.process(model, argh)
- expect(TBD.status).to be_zero
+ expect(TBD.warn?).to be true
+ expect(TBD.logs.size).to eq(4)
+ expect(TBD.logs[0][:message]).to include("Negative ")
+ expect(TBD.logs[2][:message]).to include("Negative ")
+ expect(TBD.logs[0][:message]).to include(" new Uo' (TBD::uo)")
+ expect(TBD.logs[2][:message]).to include(" new Uo' (TBD::uo)")
+ expect(TBD.logs[1][:message]).to include("Unable to completely uprate ")
+ expect(TBD.logs[3][:message]).to include("Unable to completely uprate ")
expect(json).to be_a(Hash)
expect(json).to have_key(:io)
expect(json).to have_key(:surfaces)
@@ -2227,6 +2480,8 @@
file = File.join(__dir__, "files/osms/out/office_attic_sky.osm")
model.save(file, true)
+ TBD.clean!
+
# -- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- -- #
# 5Zone_2 test case (as INDIRECTLYCONDITIONED plenum).
plenum_walls = []
@@ -2254,11 +2509,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 unless TBD.logs.empty?
expect(TBD.status).to be_zero
- argh = { option: "uncompliant (Quebec)" }
-
- json = TBD.process(model, argh)
+ argh = { option: "uncompliant (Quebec)" }
+ json = TBD.process(model, argh)
expect(json).to be_a(Hash)
expect(json).to have_key(:io)
expect(json).to have_key(:surfaces)
@@ -2316,8 +2571,7 @@
model.save(file, true)
argh = { option: "uncompliant (Quebec)" }
-
- json = TBD.process(model, argh)
+ json = TBD.process(model, argh)
expect(json).to be_a(Hash)
expect(json).to have_key(:io)
expect(json).to have_key(:surfaces)
@@ -2462,9 +2716,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)
@@ -2559,7 +2812,13 @@
c = c.get.to_LayeredConstruction
expect(c).to_not be_empty
c = c.get
- expect(TBD.rsi(c, s.filmResistance)).to be_within(TOL).of(6.38)
+
+ rsi1 = s.filmResistance
+ rsi2 = TBD.filmResistances(:roof)
+ rsi3 = TBD.filmResistances(:roof, s.tilt)
+ expect(TBD.rsi(c, rsi1)).to be_within(TOL).of(6.38)
+ expect(TBD.rsi(c, rsi2)).to be_within(TOL).of(6.31)
+ expect(TBD.rsi(c, rsi3)).to be_within(TOL).of(6.31)
construction = c if construction.nil?
expect(c).to eq(construction)
@@ -2653,6 +2912,7 @@
expect(surface).to be_a(Hash)
expect(surface).to have_key(:conditioned)
+ expect(surface).to have_key(:filmRSI)
expect(surface).to have_key(:deratable)
expect(surface).to have_key(:construction)
expect(surface).to have_key(:ground)
@@ -2681,7 +2941,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])
@@ -2777,8 +3037,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)
@@ -2950,6 +3209,7 @@
surfaces.each do |nom, surface|
expect(surface).to be_a(Hash)
expect(surface).to have_key(:conditioned)
+ expect(surface).to have_key(:filmRSI)
expect(surface).to have_key(:deratable)
expect(surface).to have_key(:construction)
expect(surface).to have_key(:ground)
@@ -2982,7 +3242,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])
@@ -3213,7 +3473,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)
@@ -3247,7 +3507,7 @@
end
end
- it "can check for balcony sills (ASHRAE 90.1 2022)" do
+ it "can check for balcony sills (ASHRAE 90.1 2022/25)" do
translator = OpenStudio::OSVersion::VersionTranslator.new
TBD.clean!
@@ -3261,8 +3521,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)
@@ -3365,32 +3624,26 @@
# Turin. The model nonetheless remains an interesting (~extreme) test case
# for TBD. Except along the South parapet, the transition from "wall-to-roof"
# and "roof-to-skylight" are one and the same. So is the edge a :skylight
- # edge? or a :parapet (or :roof) edge? They're both. In such cases, the final
- # selection in TBD is based on the greatest PSI-factor. In ASHRAE 90.1 2022,
- # only "vertical fenestration" edge PSI-factors are explicitely
- # stated/published. For this reason, the 8x TBD-built-in ASHRAE PSI sets
- # have 0 W/K per meter assigned for any non-regulated edge, e.g.:
+ # edge? or a :parapet (or :roof) edge? They're both. In such cases, the
+ # final selection in TBD is based on the greatest PSI-factor.
+ #
+ # In ASHRAE 90.1 2022/2025, only "vertical fenestration" edge PSI-factors
+ # are explicitely stated/published. Many other edges, such as:
#
# - skylight perimeters
# - non-fenestrated door perimeters
# - corners
#
- # There are (possibly) 2x admissible interpretations of how to treat
- # non-regulated heat losss (edges as linear thermal bridges) in 90.1:
- # 1. assign 0 W/K•m for both proposed design and budget building models
- # 2. assign more realistic PSI-factors, equally to both proposed/budget
+ # ... fall under the scope of requirement 5.5.5.5. There is much uncertainty
+ # on how to model items falling under 5.5.5.5. For this reason, the 8x
+ # TBD-built-in ASHRAE PSI sets have 0 W/K per meter assigned for edges under
+ # 5.5.5.5. This is discussed here:
#
- # In both cases, the treatment of non-regulated heat loss remains "neutral"
- # between both proposed design and budget building models. Option #2 remains
- # closer to reality (more heat loss in winter, likely more heat gain in
- # summer), which is preferable for HVAC autosizing. Yet 90.1 (2022) ECB
- # doesn't seem to afford this type of flexibility, contrary to the "neutral"
- # treatment of (non-regulated) miscellaneous (process) loads. So for now,
- # TBD's built-in ASHRAE 90.1 2022 (A10) PSI-factor sets recflect option #1.
+ # unmethours.com/question/97085/901-2022-requirements-for-linear-thermal-bridges
#
- # Users who choose option #2 can always write up a custom ASHRAE 90.1 (A10)
- # PSI-factor set on file (tbd.json), initially based on the built-in 90.1
- # sets while resetting non-zero PSI-factors.
+ # Users can always write up a custom ASHRAE 90.1 (A10) PSI-factor set on
+ # file (tbd.json), initially based on the built-in 90.1 sets while resetting
+ # non-zero PSI-factors.
expect(n_edges_at_grade ).to eq( 0)
expect(n_edges_as_balconies ).to eq( 2)
expect(n_edges_as_balconysills ).to eq( 2) # (2x instances of GlassDoor)
@@ -3495,8 +3748,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)
@@ -3613,8 +3865,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)
@@ -3701,8 +3952,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)
@@ -3762,7 +4012,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
@@ -3774,8 +4024,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)
@@ -3837,7 +4086,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
@@ -3917,7 +4166,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
@@ -4005,7 +4254,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