tbd 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.gitattributes +3 -0
- data/.github/workflows/pull_request.yml +72 -0
- data/.gitignore +23 -0
- data/.rspec +3 -0
- data/Gemfile +3 -0
- data/LICENSE.md +21 -0
- data/README.md +154 -0
- data/Rakefile +60 -0
- data/json/midrise.json +64 -0
- data/json/tbd_5ZoneNoHVAC.json +19 -0
- data/json/tbd_5ZoneNoHVAC_btap.json +91 -0
- data/json/tbd_seb_n2.json +41 -0
- data/json/tbd_seb_n4.json +57 -0
- data/json/tbd_warehouse10.json +24 -0
- data/json/tbd_warehouse5.json +37 -0
- data/lib/measures/tbd/LICENSE.md +21 -0
- data/lib/measures/tbd/README.md +136 -0
- data/lib/measures/tbd/README.md.erb +42 -0
- data/lib/measures/tbd/docs/.gitkeep +1 -0
- data/lib/measures/tbd/measure.rb +327 -0
- data/lib/measures/tbd/measure.xml +460 -0
- data/lib/measures/tbd/resources/geo.rb +714 -0
- data/lib/measures/tbd/resources/geometry.rb +351 -0
- data/lib/measures/tbd/resources/model.rb +1431 -0
- data/lib/measures/tbd/resources/oslog.rb +381 -0
- data/lib/measures/tbd/resources/psi.rb +2229 -0
- data/lib/measures/tbd/resources/tbd.rb +55 -0
- data/lib/measures/tbd/resources/transformation.rb +121 -0
- data/lib/measures/tbd/resources/ua.rb +986 -0
- data/lib/measures/tbd/resources/utils.rb +1636 -0
- data/lib/measures/tbd/resources/version.rb +3 -0
- data/lib/measures/tbd/tests/tbd_full_PSI.json +17 -0
- data/lib/measures/tbd/tests/tbd_tests.rb +222 -0
- data/lib/tbd/geo.rb +714 -0
- data/lib/tbd/psi.rb +2229 -0
- data/lib/tbd/ua.rb +986 -0
- data/lib/tbd/version.rb +25 -0
- data/lib/tbd.rb +93 -0
- data/sponsors/canada.png +0 -0
- data/sponsors/quebec.png +0 -0
- data/tbd.gemspec +43 -0
- data/tbd.schema.json +571 -0
- data/v291_MacOS.md +110 -0
- metadata +191 -0
data/lib/tbd/geo.rb
ADDED
@@ -0,0 +1,714 @@
|
|
1
|
+
# MIT License
|
2
|
+
#
|
3
|
+
# Copyright (c) 2020-2022 Denis Bourgeois & Dan Macumber
|
4
|
+
#
|
5
|
+
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
# of this software and associated documentation files (the "Software"), to deal
|
7
|
+
# in the Software without restriction, including without limitation the rights
|
8
|
+
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
# copies of the Software, and to permit persons to whom the Software is
|
10
|
+
# furnished to do so, subject to the following conditions:
|
11
|
+
#
|
12
|
+
# The above copyright notice and this permission notice shall be included in all
|
13
|
+
# copies or substantial portions of the Software.
|
14
|
+
#
|
15
|
+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
21
|
+
# SOFTWARE.
|
22
|
+
|
23
|
+
module TBD
|
24
|
+
##
|
25
|
+
# Check for matching Topolys vertex pairs between edges (within TOL).
|
26
|
+
#
|
27
|
+
# @param e1 [Hash] first edge
|
28
|
+
# @param e2 [Hash] second edge
|
29
|
+
#
|
30
|
+
# @return [Bool] true if edges share vertex pairs
|
31
|
+
# @return [Bool] false if invalid input
|
32
|
+
def matches?(e1 = {}, e2 = {})
|
33
|
+
mth = "TBD::#{__callee__}"
|
34
|
+
cl = Topolys::Point3D
|
35
|
+
a = false
|
36
|
+
|
37
|
+
return mismatch("e1", e1, Hash, mth, DBG, a) unless e1.is_a?(Hash)
|
38
|
+
return mismatch("e2", e2, Hash, mth, DBG, a) unless e2.is_a?(Hash)
|
39
|
+
return hashkey("e1", e1, :v0, mth, DBG, a) unless e1.key?(:v0)
|
40
|
+
return hashkey("e1", e1, :v1, mth, DBG, a) unless e1.key?(:v1)
|
41
|
+
return hashkey("e2", e2, :v0, mth, DBG, a) unless e2.key?(:v0)
|
42
|
+
return hashkey("e2", e2, :v1, mth, DBG, a) unless e2.key?(:v1)
|
43
|
+
return mismatch("e1 :v0", e1[:v0], cl, mth, DBG, a) unless e1[:v0].is_a?(cl)
|
44
|
+
return mismatch("e1 :v1", e1[:v1], cl, mth, DBG, a) unless e1[:v1].is_a?(cl)
|
45
|
+
return mismatch("e2 :v0", e2[:v0], cl, mth, DBG, a) unless e2[:v0].is_a?(cl)
|
46
|
+
return mismatch("e2 :v1", e2[:v1], cl, mth, DBG, a) unless e2[:v1].is_a?(cl)
|
47
|
+
|
48
|
+
e1_vector = e1[:v1] - e1[:v0]
|
49
|
+
e2_vector = e2[:v1] - e2[:v0]
|
50
|
+
|
51
|
+
return zero("e1", mth, DBG, a) if e1_vector.magnitude < TOL
|
52
|
+
return zero("e2", mth, DBG, a) if e2_vector.magnitude < TOL
|
53
|
+
|
54
|
+
return true if
|
55
|
+
(
|
56
|
+
(
|
57
|
+
( (e1[:v0].x - e2[:v0].x).abs < TOL &&
|
58
|
+
(e1[:v0].y - e2[:v0].y).abs < TOL &&
|
59
|
+
(e1[:v0].z - e2[:v0].z).abs < TOL
|
60
|
+
) ||
|
61
|
+
( (e1[:v0].x - e2[:v1].x).abs < TOL &&
|
62
|
+
(e1[:v0].y - e2[:v1].y).abs < TOL &&
|
63
|
+
(e1[:v0].z - e2[:v1].z).abs < TOL
|
64
|
+
)
|
65
|
+
) &&
|
66
|
+
(
|
67
|
+
( (e1[:v1].x - e2[:v0].x).abs < TOL &&
|
68
|
+
(e1[:v1].y - e2[:v0].y).abs < TOL &&
|
69
|
+
(e1[:v1].z - e2[:v0].z).abs < TOL
|
70
|
+
) ||
|
71
|
+
( (e1[:v1].x - e2[:v1].x).abs < TOL &&
|
72
|
+
(e1[:v1].y - e2[:v1].y).abs < TOL &&
|
73
|
+
(e1[:v1].z - e2[:v1].z).abs < TOL
|
74
|
+
)
|
75
|
+
)
|
76
|
+
)
|
77
|
+
|
78
|
+
false
|
79
|
+
end
|
80
|
+
|
81
|
+
##
|
82
|
+
# Return Topolys vertices and a Topolys wire from Topolys points. As a side
|
83
|
+
# effect, it will - if successful - also populate the Topolys model with the
|
84
|
+
# vertices and wire.
|
85
|
+
#
|
86
|
+
# @param model [Topolys::Model] a model
|
87
|
+
# @param pts [Array] a 1D array of 3D Topolys points (min 2x)
|
88
|
+
#
|
89
|
+
# @return [Hash] vx: 3D Topolys vertices Array; w: corresponding Topolys::Wire
|
90
|
+
# @return [Hash] vx: nil; w: nil (if invalid input)
|
91
|
+
def objects(model = nil, pts = [])
|
92
|
+
mth = "OSut::#{__callee__}"
|
93
|
+
cl = Topolys::Model
|
94
|
+
obj = { vx: nil, w: nil }
|
95
|
+
|
96
|
+
return mismatch("model", model, cl, mth, DBG, obj) unless model.is_a?(cl)
|
97
|
+
return mismatch("points", pts, Array, mth, DBG, obj) unless pts.is_a?(Array)
|
98
|
+
|
99
|
+
log(DBG, "#{pts.size}? need +2 Topolys points (#{mth})") unless pts.size > 2
|
100
|
+
return obj unless pts.size > 2
|
101
|
+
obj[:vx] = model.get_vertices(pts)
|
102
|
+
obj[:w ] = model.get_wire(obj[:vx])
|
103
|
+
|
104
|
+
obj
|
105
|
+
end
|
106
|
+
|
107
|
+
##
|
108
|
+
# Populate collection of TBD hinged 'kids' (subsurfaces), relying on Topolys.
|
109
|
+
# As a side effect, it will - if successful - also populate a Topolys 'model'
|
110
|
+
# with Topolys vertices, wires, holes. In rare cases such as domes of tubular
|
111
|
+
# daylighting devices (TDDs), kids may be 'unhinged', i.e. not on same 3D
|
112
|
+
# plane as 'dad(s)' - TBD corrects auch cases elsewhere.
|
113
|
+
#
|
114
|
+
# @param model [Topolys::Model] a model
|
115
|
+
# @param boys [Hash] a collection of TBD subsurfaces
|
116
|
+
#
|
117
|
+
# @return [Array] 3D Topolys wires of 'holes' (made by kids)
|
118
|
+
def kids(model = nil, boys = {})
|
119
|
+
mth = "OSut::#{__callee__}"
|
120
|
+
cl = Topolys::Model
|
121
|
+
holes = []
|
122
|
+
|
123
|
+
return mismatch("model", model, cl, mth, DBG, holes) unless model.is_a?(cl)
|
124
|
+
return mismatch("boys", boys, Hash, mth, DBG, holes) unless boys.is_a?(Hash)
|
125
|
+
|
126
|
+
boys.each do |id, props|
|
127
|
+
obj = objects(model, props[:points])
|
128
|
+
next unless obj[:w]
|
129
|
+
obj[:w].attributes[:id ] = id
|
130
|
+
obj[:w].attributes[:unhinged] = props[:unhinged] if props.key?(:unhinged)
|
131
|
+
obj[:w].attributes[:n ] = props[:n] if props.key?(:n)
|
132
|
+
props[:hole] = obj[:w]
|
133
|
+
holes << obj[:w]
|
134
|
+
end
|
135
|
+
|
136
|
+
holes
|
137
|
+
end
|
138
|
+
|
139
|
+
##
|
140
|
+
# Populate hash of TBD 'dads' (parent) surfaces, relying on Topolys. As a side
|
141
|
+
# effect, it will - if successful - also populate the main Topolys model with
|
142
|
+
# Topolys vertices, wires, holes & faces.
|
143
|
+
#
|
144
|
+
# @param model [Topolys::Model] a model
|
145
|
+
# @param pops [Hash] a collection of TBD (parent) surfaces
|
146
|
+
#
|
147
|
+
# @return [Array] 3D Topolys wires of 'holes' (made by kids)
|
148
|
+
def dads(model = nil, pops = {})
|
149
|
+
mth = "OSut::#{__callee__}"
|
150
|
+
cl = Topolys::Model
|
151
|
+
holes = {}
|
152
|
+
|
153
|
+
return mismatch("model", model, cl, mth, DBG, holes) unless model.is_a?(cl)
|
154
|
+
return mismatch("pops", pops, Hash, mth, DBG, holes) unless pops.is_a?(Hash)
|
155
|
+
|
156
|
+
pops.each do |id, props|
|
157
|
+
hols = []
|
158
|
+
hinged = []
|
159
|
+
obj = objects(model, props[:points])
|
160
|
+
next unless obj[:vx] && obj[:w]
|
161
|
+
hols += kids(model, props[:windows ]) if props.key?(:windows )
|
162
|
+
hols += kids(model, props[:doors ]) if props.key?(:doors )
|
163
|
+
hols += kids(model, props[:skylights]) if props.key?(:skylights)
|
164
|
+
hols.each { |hol| hinged << hol unless hol.attributes[:unhinged] }
|
165
|
+
face = model.get_face(obj[:w], hinged)
|
166
|
+
log(DBG, "Unable to retrieve valid 'dad' (#{mth})") unless face
|
167
|
+
next unless face
|
168
|
+
face.attributes[:id] = id
|
169
|
+
face.attributes[:n] = props[:n] if props.key?(:n)
|
170
|
+
props[:face] = face
|
171
|
+
hols.each { |hol| holes[hol.attributes[:id]] = hol }
|
172
|
+
end
|
173
|
+
|
174
|
+
holes
|
175
|
+
end
|
176
|
+
|
177
|
+
##
|
178
|
+
# Populate TBD edges with linked Topolys faces.
|
179
|
+
#
|
180
|
+
# @param s [Hash] a collection of TBD surfaces
|
181
|
+
# @param e [Hash] a collection TBD edges
|
182
|
+
#
|
183
|
+
# @return [Bool] true if successful
|
184
|
+
# @return [Bool] false if invalid input
|
185
|
+
def faces(s = {}, e = {})
|
186
|
+
mth = "OSut::#{__callee__}"
|
187
|
+
|
188
|
+
return mismatch("surfaces", s, Hash, mth, DBG, false) unless s.is_a?(Hash)
|
189
|
+
return mismatch("edges", e, Hash, mth, DBG, false) unless e.is_a?(Hash)
|
190
|
+
|
191
|
+
s.each do |id, props|
|
192
|
+
log(DBG, "Missing Topolys face '#{id}' (#{mth})") unless props.key?(:face)
|
193
|
+
next unless props.key?(:face)
|
194
|
+
|
195
|
+
props[:face].wires.each do |wire|
|
196
|
+
wire.edges.each do |edge|
|
197
|
+
unless e.key?(edge.id)
|
198
|
+
e[edge.id] = { length: edge.length,
|
199
|
+
v0: edge.v0,
|
200
|
+
v1: edge.v1,
|
201
|
+
surfaces: {} }
|
202
|
+
end
|
203
|
+
|
204
|
+
unless e[edge.id][:surfaces].key?(id)
|
205
|
+
e[edge.id][:surfaces][id] = { wire: wire.id }
|
206
|
+
end
|
207
|
+
end
|
208
|
+
end
|
209
|
+
end
|
210
|
+
|
211
|
+
true
|
212
|
+
end
|
213
|
+
|
214
|
+
##
|
215
|
+
# Return site-specific (or true) Topolys normal vector of OpenStudio surface.
|
216
|
+
#
|
217
|
+
# @param s [OpenStudio::Model::PlanarSurface] a planar surface
|
218
|
+
# @param r [Float] a group/site rotation angle [0,2PI) radians
|
219
|
+
#
|
220
|
+
# @return [Topolys::Vector3D] normal (Topolys) vector <x,y,z> of s
|
221
|
+
# @return [NilClass] if invalid input
|
222
|
+
def trueNormal(s = nil, r = 0)
|
223
|
+
mth = "TBD::#{__callee__}"
|
224
|
+
cl = OpenStudio::Model::PlanarSurface
|
225
|
+
|
226
|
+
return mismatch("surface", s, cl, mth) unless s.is_a?(cl)
|
227
|
+
return invalid("rotation angle", mth, 2) unless r.respond_to?(:to_f)
|
228
|
+
|
229
|
+
r = -r.to_f * Math::PI / 180.0
|
230
|
+
vx = s.outwardNormal.x * Math.cos(r) - s.outwardNormal.y * Math.sin(r)
|
231
|
+
vy = s.outwardNormal.x * Math.sin(r) + s.outwardNormal.y * Math.cos(r)
|
232
|
+
vz = s.outwardNormal.z
|
233
|
+
Topolys::Vector3D.new(vx, vy, vz)
|
234
|
+
end
|
235
|
+
|
236
|
+
##
|
237
|
+
# Fetch OpenStudio surface properties, including opening areas & vertices.
|
238
|
+
#
|
239
|
+
# @param model [OpenStudio::Model::Model] a model
|
240
|
+
# @param surface [OpenStudio::Model::Surface] a surface
|
241
|
+
#
|
242
|
+
# @return [Hash] TBD surface with key attributes, including openings
|
243
|
+
# @return [NilClass] if invalid input
|
244
|
+
def properties(model = nil, surface = nil)
|
245
|
+
mth = "TBD::#{__callee__}"
|
246
|
+
cl1 = OpenStudio::Model::Model
|
247
|
+
cl2 = OpenStudio::Model::Surface
|
248
|
+
cl3 = OpenStudio::Model::LayeredConstruction
|
249
|
+
|
250
|
+
return mismatch("model", model, cl1, mth) unless model.is_a?(cl1)
|
251
|
+
return mismatch("surface", surface, cl2, mth) unless surface.is_a?(cl2)
|
252
|
+
|
253
|
+
nom = surface.nameString
|
254
|
+
surf = {}
|
255
|
+
subs = {}
|
256
|
+
fd = false
|
257
|
+
return empty("'#{nom}' space", mth, ERR) if surface.space.empty?
|
258
|
+
space = surface.space.get
|
259
|
+
stype = space.spaceType
|
260
|
+
story = space.buildingStory
|
261
|
+
tr = transforms(model, space)
|
262
|
+
return invalid("'#{nom}' transform", mth, 0, FTL) unless tr[:t] && tr[:r]
|
263
|
+
t = tr[:t]
|
264
|
+
n = trueNormal(surface, tr[:r])
|
265
|
+
return invalid("'#{nom}' normal", mth, 0, FTL) unless n
|
266
|
+
type = surface.surfaceType.downcase
|
267
|
+
facing = surface.outsideBoundaryCondition
|
268
|
+
|
269
|
+
if facing.downcase == "surface"
|
270
|
+
empty = surface.adjacentSurface.empty?
|
271
|
+
return invalid("'#{nom}': adjacent surface", mth, 0, ERR) if empty
|
272
|
+
facing = surface.adjacentSurface.get.nameString
|
273
|
+
end
|
274
|
+
|
275
|
+
unless surface.construction.empty?
|
276
|
+
construction = surface.construction.get.to_LayeredConstruction
|
277
|
+
|
278
|
+
unless construction.empty?
|
279
|
+
construction = construction.get
|
280
|
+
lyr = insulatingLayer(construction)
|
281
|
+
lyr[:index] = nil unless lyr[:index].is_a?(Numeric)
|
282
|
+
lyr[:index] = nil unless lyr[:index] >= 0
|
283
|
+
lyr[:index] = nil unless lyr[:index] < construction.layers.size
|
284
|
+
|
285
|
+
if lyr[:index]
|
286
|
+
surf[:construction] = construction
|
287
|
+
# index: ... of layer/material (to derate) within construction
|
288
|
+
# ltype: either :massless (RSi) or :standard (k + d)
|
289
|
+
# r : initial RSi value of the indexed layer to derate
|
290
|
+
surf[:index] = lyr[:index]
|
291
|
+
surf[:ltype] = lyr[:type ]
|
292
|
+
surf[:r ] = lyr[:r ]
|
293
|
+
end
|
294
|
+
end
|
295
|
+
end
|
296
|
+
|
297
|
+
surf[:conditioned] = true
|
298
|
+
surf[:space ] = space
|
299
|
+
surf[:boundary ] = facing
|
300
|
+
surf[:ground ] = surface.isGroundSurface
|
301
|
+
surf[:type ] = :floor
|
302
|
+
surf[:type ] = :ceiling if type.include?("ceiling")
|
303
|
+
surf[:type ] = :wall if type.include?("wall" )
|
304
|
+
surf[:stype ] = stype.get unless stype.empty?
|
305
|
+
surf[:story ] = story.get unless story.empty?
|
306
|
+
surf[:n ] = n
|
307
|
+
surf[:gross ] = surface.grossArea
|
308
|
+
|
309
|
+
surface.subSurfaces.sort_by { |s| s.nameString }.each do |s|
|
310
|
+
id = s.nameString
|
311
|
+
valid = s.vertices.size == 3 || s.vertices.size == 4
|
312
|
+
log(ERR, "Skipping '#{id}': vertex # 3 or 4 (#{mth})") unless valid
|
313
|
+
next unless valid
|
314
|
+
vec = s.vertices
|
315
|
+
area = s.grossArea
|
316
|
+
typ = s.subSurfaceType.downcase
|
317
|
+
type = :skylight
|
318
|
+
type = :window if typ.include?("window" )
|
319
|
+
type = :door if typ.include?("door" )
|
320
|
+
glazed = type == :door && typ.include?("glass" )
|
321
|
+
tubular = typ.include?("tubular")
|
322
|
+
domed = typ.include?("dome" )
|
323
|
+
unhinged = false
|
324
|
+
|
325
|
+
# Determine if TDD dome subsurface is unhinged i.e. unconnected to parent.
|
326
|
+
if domed
|
327
|
+
unhinged = true unless s.plane.equal(surface.plane)
|
328
|
+
n = s.outwardNormal if unhinged
|
329
|
+
end
|
330
|
+
|
331
|
+
log(ERR, "Skipping '#{id}': gross area ~zero (#{mth})") if area < TOL
|
332
|
+
next if area < TOL
|
333
|
+
c = s.construction
|
334
|
+
log(ERR, "Skipping '#{id}': missing construction (#{mth})") if c.empty?
|
335
|
+
next if c.empty?
|
336
|
+
c = c.get.to_LayeredConstruction
|
337
|
+
log(ERR, "Skipping '#{id}': must be a #{cl3} (#{mth})") if c.empty?
|
338
|
+
next if c.empty?
|
339
|
+
c = c.get
|
340
|
+
|
341
|
+
# A subsurface may have an overall U-factor set by the user - a less
|
342
|
+
# accurate option, yet easier to process (and often the only option
|
343
|
+
# available). With EnergyPlus' "simple window" model, a subsurface's
|
344
|
+
# construction has a single SimpleGlazing material/layer holding the
|
345
|
+
# whole product U-factor.
|
346
|
+
#
|
347
|
+
# https://bigladdersoftware.com/epx/docs/9-6/engineering-reference/
|
348
|
+
# window-calculation-module.html#simple-window-model
|
349
|
+
#
|
350
|
+
# TBD will instead rely on Tubular Daylighting Device (TDD) effective
|
351
|
+
# dome-to-diffuser RSi values (if valid).
|
352
|
+
#
|
353
|
+
# https://bigladdersoftware.com/epx/docs/9-6/engineering-reference/
|
354
|
+
# daylighting-devices.html#tubular-daylighting-devices
|
355
|
+
#
|
356
|
+
# In other cases, TBD will recover an 'additional property' tagged
|
357
|
+
# "uFactor", assigned either to the individual subsurface itself, or else
|
358
|
+
# assigned to its referenced construction (a more generic fallback).
|
359
|
+
#
|
360
|
+
# If all else fails, TBD will calculate an approximate whole product
|
361
|
+
# U-factor by adding up the subsurface's layered construction material
|
362
|
+
# thermal resistances (as well as the subsurface's parent surface film
|
363
|
+
# resistances). This is the least reliable option, especially if
|
364
|
+
# subsurfaces have Frame & Divider objects, or irregular geometry.
|
365
|
+
u = s.uFactor
|
366
|
+
u = u.get unless u.empty?
|
367
|
+
|
368
|
+
if tubular & s.respond_to?(:daylightingDeviceTubular) # OSM > v3.3.0
|
369
|
+
unless s.daylightingDeviceTubular.empty?
|
370
|
+
r = s.daylightingDeviceTubular.get.effectiveThermalResistance
|
371
|
+
u = 1 / r if r > TOL
|
372
|
+
end
|
373
|
+
end
|
374
|
+
|
375
|
+
unless u.is_a?(Numeric)
|
376
|
+
u = s.additionalProperties.getFeatureAsDouble("uFactor")
|
377
|
+
end
|
378
|
+
|
379
|
+
unless u.is_a?(Numeric)
|
380
|
+
r = rsi(c, surface.filmResistance)
|
381
|
+
log(ERR, "Skipping '#{id}': U-factor unavailable (#{mth})") if r < TOL
|
382
|
+
next if r < TOL
|
383
|
+
u = 1 / r
|
384
|
+
end
|
385
|
+
|
386
|
+
frame = s.allowWindowPropertyFrameAndDivider
|
387
|
+
frame = false if s.windowPropertyFrameAndDivider.empty?
|
388
|
+
|
389
|
+
if frame
|
390
|
+
fd = true
|
391
|
+
width = s.windowPropertyFrameAndDivider.get.frameWidth
|
392
|
+
vec = offset(vec, width, 300)
|
393
|
+
area = OpenStudio.getArea(vec)
|
394
|
+
log(ERR, "Skipping '#{id}': invalid offset (#{mth})") if area.empty?
|
395
|
+
next if area.empty?
|
396
|
+
area = area.get
|
397
|
+
end
|
398
|
+
|
399
|
+
sub = { v: s.vertices,
|
400
|
+
points: vec,
|
401
|
+
n: n,
|
402
|
+
gross: s.grossArea,
|
403
|
+
area: area,
|
404
|
+
type: type,
|
405
|
+
u: u,
|
406
|
+
unhinged: unhinged }
|
407
|
+
|
408
|
+
sub[:glazed] = true if glazed
|
409
|
+
subs[id] = sub
|
410
|
+
end
|
411
|
+
|
412
|
+
valid = true
|
413
|
+
# Test for conflicts (with fits?, overlaps?) between sub/surfaces to
|
414
|
+
# determine whether to keep original points or switch to std::vector of
|
415
|
+
# revised coordinates, offset by Frame & Divider frame width. This will
|
416
|
+
# also inadvertently catch pre-existing (yet nonetheless invalid)
|
417
|
+
# OpenStudio inputs (without Frame & Dividers).
|
418
|
+
subs.each do |id, sub|
|
419
|
+
break unless fd
|
420
|
+
break unless valid
|
421
|
+
valid = fits?(sub[:points], surface.vertices, id, nom)
|
422
|
+
log(ERR, "Skipping '#{id}': can't fit in '#{nom}' (#{mth})") unless valid
|
423
|
+
|
424
|
+
subs.each do |i, sb|
|
425
|
+
break unless valid
|
426
|
+
next if i == id
|
427
|
+
oops = overlaps?(sb[:points], sub[:points], id, nom)
|
428
|
+
log(ERR, "Skipping '#{id}': overlaps sibling '#{i}' (#{mth})") if oops
|
429
|
+
valid = false if oops
|
430
|
+
end
|
431
|
+
end
|
432
|
+
|
433
|
+
if fd
|
434
|
+
subs.values.each { |sub| sub[:gross ] = sub[:area ] } if valid
|
435
|
+
subs.values.each { |sub| sub[:points] = sub[:v ] } unless valid
|
436
|
+
subs.values.each { |sub| sub[:area ] = sub[:gross] } unless valid
|
437
|
+
end
|
438
|
+
|
439
|
+
subarea = 0
|
440
|
+
subs.values.each { |sub| subarea += sub[:area] }
|
441
|
+
surf[:net] = surf[:gross] - subarea
|
442
|
+
|
443
|
+
# Tranform final Point 3D sets, and store.
|
444
|
+
pts = (t * surface.vertices).map { |v| Topolys::Point3D.new(v.x, v.y, v.z) }
|
445
|
+
surf[:points] = pts
|
446
|
+
surf[:minz ] = ( pts.map { |pt| pt.z } ).min
|
447
|
+
|
448
|
+
subs.each do |id, sub|
|
449
|
+
pts = (t * sub[:points]).map { |v| Topolys::Point3D.new(v.x, v.y, v.z) }
|
450
|
+
sub[:points] = pts
|
451
|
+
sub[:minz ] = ( pts.map { |p| p.z } ).min
|
452
|
+
|
453
|
+
[:windows, :doors, :skylights].each do |types|
|
454
|
+
type = types.slice(0..-2).to_sym
|
455
|
+
|
456
|
+
if sub[:type] == type
|
457
|
+
surf[types] = {} unless surf.key?(types)
|
458
|
+
surf[types][id] = sub
|
459
|
+
end
|
460
|
+
end
|
461
|
+
end
|
462
|
+
|
463
|
+
surf
|
464
|
+
end
|
465
|
+
|
466
|
+
##
|
467
|
+
# Validate whether edge surfaces form a concave angle, as seen from outside.
|
468
|
+
#
|
469
|
+
# @param s1 [Surface] first TBD surface
|
470
|
+
# @param s2 [Surface] second TBD surface
|
471
|
+
#
|
472
|
+
# @return [Bool] true if angle between surfaces is concave
|
473
|
+
# @return [Bool] false if invalid input
|
474
|
+
def concave?(s1 = nil, s2 = nil)
|
475
|
+
mth = "TBD::#{__callee__}"
|
476
|
+
|
477
|
+
return mismatch("s1", s1, Hash, mth, DBG, false) unless s1.is_a?(Hash)
|
478
|
+
return mismatch("s2", s2, Hash, mth, DBG, false) unless s2.is_a?(Hash)
|
479
|
+
return hashkey("s1", s1, :angle, mth, DBG, false) unless s1.key?(:angle)
|
480
|
+
return hashkey("s2", s2, :angle, mth, DBG, false) unless s2.key?(:angle)
|
481
|
+
return hashkey("s1", s1, :normal, mth, DBG, false) unless s1.key?(:normal)
|
482
|
+
return hashkey("s2", s2, :normal, mth, DBG, false) unless s2.key?(:normal)
|
483
|
+
return hashkey("s1", s1, :polar, mth, DBG, false) unless s1.key?(:polar)
|
484
|
+
return hashkey("s2", s2, :polar, mth, DBG, false) unless s2.key?(:polar)
|
485
|
+
valid1 = s1[:angle].is_a?(Numeric)
|
486
|
+
valid2 = s2[:angle].is_a?(Numeric)
|
487
|
+
return mismatch("s1 angle", s1[:angle], Numeric, DBG, false) unless valid1
|
488
|
+
return mismatch("s1 angle", s1[:angle], Numeric, DBG, false) unless valid2
|
489
|
+
|
490
|
+
angle = 0
|
491
|
+
angle = s2[:angle] - s1[:angle] if s2[:angle] > s1[:angle]
|
492
|
+
angle = s1[:angle] - s2[:angle] if s1[:angle] > s2[:angle]
|
493
|
+
|
494
|
+
return false if angle < TOL
|
495
|
+
return false unless (2 * Math::PI - angle).abs > TOL
|
496
|
+
return false if angle > 3 * Math::PI / 4 && angle < 5 * Math::PI / 4
|
497
|
+
|
498
|
+
n1_d_p2 = s1[:normal].dot(s2[:polar])
|
499
|
+
p1_d_n2 = s1[:polar].dot(s2[:normal])
|
500
|
+
return true if n1_d_p2 > 0 && p1_d_n2 > 0
|
501
|
+
|
502
|
+
false
|
503
|
+
end
|
504
|
+
|
505
|
+
##
|
506
|
+
# Validate whether edge surfaces form a convex angle, as seen from outside.
|
507
|
+
#
|
508
|
+
# @param s1 [Surface] first TBD surface
|
509
|
+
# @param s2 [Surface] second TBD surface
|
510
|
+
#
|
511
|
+
# @return [Bool] true if angle between surfaces is convex
|
512
|
+
# @return [Bool] false if invalid input
|
513
|
+
def convex?(s1 = nil, s2 = nil)
|
514
|
+
mth = "TBD::#{__callee__}"
|
515
|
+
|
516
|
+
return mismatch("s1", s1, Hash, mth, DBG, false) unless s1.is_a?(Hash)
|
517
|
+
return mismatch("s2", s2, Hash, mth, DBG, false) unless s2.is_a?(Hash)
|
518
|
+
return hashkey("s1", s1, :angle, mth, DBG, false) unless s1.key?(:angle)
|
519
|
+
return hashkey("s2", s2, :angle, mth, DBG, false) unless s2.key?(:angle)
|
520
|
+
return hashkey("s1", s1, :normal, mth, DBG, false) unless s1.key?(:normal)
|
521
|
+
return hashkey("s2", s2, :normal, mth, DBG, false) unless s2.key?(:normal)
|
522
|
+
return hashkey("s1", s1, :polar, mth, DBG, false) unless s1.key?(:polar)
|
523
|
+
return hashkey("s2", s2, :polar, mth, DBG, false) unless s2.key?(:polar)
|
524
|
+
valid1 = s1[:angle].is_a?(Numeric)
|
525
|
+
valid2 = s2[:angle].is_a?(Numeric)
|
526
|
+
return mismatch("s1 angle", s1[:angle], Numeric, DBG, false) unless valid1
|
527
|
+
return mismatch("s1 angle", s1[:angle], Numeric, DBG, false) unless valid2
|
528
|
+
|
529
|
+
angle = 0
|
530
|
+
angle = s2[:angle] - s1[:angle] if s2[:angle] > s1[:angle]
|
531
|
+
angle = s1[:angle] - s2[:angle] if s1[:angle] > s2[:angle]
|
532
|
+
|
533
|
+
return false if angle < TOL
|
534
|
+
return false unless (2 * Math::PI - angle).abs > TOL
|
535
|
+
return false if angle > 3 * Math::PI / 4 && angle < 5 * Math::PI / 4
|
536
|
+
|
537
|
+
n1_d_p2 = s1[:normal].dot(s2[:polar])
|
538
|
+
p1_d_n2 = s1[:polar].dot(s2[:normal])
|
539
|
+
return true if n1_d_p2 < 0 && p1_d_n2 < 0
|
540
|
+
|
541
|
+
false
|
542
|
+
end
|
543
|
+
|
544
|
+
##
|
545
|
+
# Generate Kiva settings and objects if model surfaces have 'foundation'
|
546
|
+
# boundary conditions.
|
547
|
+
#
|
548
|
+
# @param model [OpenStudio::Model::Model] a model
|
549
|
+
# @param floors [Hash] TBD floors
|
550
|
+
# @param walls [Hash] TBD walls
|
551
|
+
# @param edges [Hash] TBD edges (many linking floors & walls
|
552
|
+
#
|
553
|
+
# @return [Bool] true if Kiva foundations are successfully generated
|
554
|
+
# @return [Bool] false if invalid input
|
555
|
+
def kiva(model = nil, walls = {}, floors = {}, edges = {})
|
556
|
+
mth = "TBD::#{__callee__}"
|
557
|
+
cl1 = OpenStudio::Model::Model
|
558
|
+
cl2 = Hash
|
559
|
+
a = false
|
560
|
+
|
561
|
+
return mismatch("model", model, cl1, mth, DBG, a) unless model.is_a?(cl1)
|
562
|
+
return mismatch("walls", walls, cl2, mth, DBG, a) unless walls.is_a?(cl2)
|
563
|
+
return mismatch("floors", floors, cl2, mth, DBG, a) unless floors.is_a?(cl2)
|
564
|
+
return mismatch("edges", edges, cl2, mth, DBG, a) unless edges.is_a?(cl2)
|
565
|
+
|
566
|
+
# Strictly relying on Kiva's total exposed perimeter approach.
|
567
|
+
arg = "TotalExposedPerimeter"
|
568
|
+
kiva = true
|
569
|
+
# The following is loosely adapted from:
|
570
|
+
#
|
571
|
+
# github.com/NREL/OpenStudio-resources/blob/develop/model/simulationtests/
|
572
|
+
# foundation_kiva.rb ... thanks.
|
573
|
+
#
|
574
|
+
# Access to KIVA settings. This is usually not required (the default KIVA
|
575
|
+
# settings are fine), but its explicit inclusion in the model does offer
|
576
|
+
# users easy access to further tweak settings, e.g. soil properties if
|
577
|
+
# required. Initial tests show slight differences in simulation results
|
578
|
+
# w/w/o explcit inclusion of the KIVA settings template in the model.
|
579
|
+
settings = model.getFoundationKivaSettings
|
580
|
+
k = settings.soilConductivity
|
581
|
+
settings.setSoilConductivity(k)
|
582
|
+
|
583
|
+
# Tag foundation-facing floors, then walls.
|
584
|
+
edges.each do |code1, edge|
|
585
|
+
edge[:surfaces].keys.each do |id|
|
586
|
+
next unless floors.key?(id)
|
587
|
+
next unless floors[id][:boundary].downcase == "foundation"
|
588
|
+
next if floors[id].key?(:kiva)
|
589
|
+
floors[id][:kiva ] = :slab # initially slabs-on-grade
|
590
|
+
floors[id][:exposed] = 0.0 # slab-on-grade or basement walkout perimeter
|
591
|
+
|
592
|
+
edge[:surfaces].keys.each do |i| # loop around current edge
|
593
|
+
next if i == id
|
594
|
+
next unless walls.key?(i)
|
595
|
+
next unless walls[i][:boundary].downcase == "foundation"
|
596
|
+
next if walls[i].key?(:kiva)
|
597
|
+
floors[id][:kiva] = :basement
|
598
|
+
walls[i ][:kiva] = id
|
599
|
+
end
|
600
|
+
|
601
|
+
edge[:surfaces].keys.each do |i| # loop around current edge
|
602
|
+
next if i == id
|
603
|
+
next unless walls.key?(i)
|
604
|
+
next unless walls[i][:boundary].downcase == "outdoors"
|
605
|
+
floors[id][:exposed] += edge[:length]
|
606
|
+
end
|
607
|
+
|
608
|
+
edges.each do |code2, e| # loop around other floor edges
|
609
|
+
next if code1 == code2 # skip - same edge
|
610
|
+
|
611
|
+
e[:surfaces].keys.each do |i|
|
612
|
+
next unless i == id # good - same floor
|
613
|
+
|
614
|
+
e[:surfaces].keys.each do |ii|
|
615
|
+
next if i == ii
|
616
|
+
next unless walls.key?(ii)
|
617
|
+
next unless walls[ii][:boundary].downcase == "foundation"
|
618
|
+
next if walls[ii].key?(:kiva)
|
619
|
+
floors[id][:kiva] = :basement
|
620
|
+
walls[ii ][:kiva] = id
|
621
|
+
end
|
622
|
+
|
623
|
+
e[:surfaces].keys.each do |ii|
|
624
|
+
next if i == ii
|
625
|
+
next unless walls.key?(ii)
|
626
|
+
next unless walls[ii][:boundary].downcase == "outdoors"
|
627
|
+
floors[id][:exposed] += e[:length]
|
628
|
+
end
|
629
|
+
end
|
630
|
+
end
|
631
|
+
|
632
|
+
floors[id][:foundation] = OpenStudio::Model::FoundationKiva.new(model)
|
633
|
+
floors[id][:foundation].setName("KIVA Foundation Floor '#{id}'")
|
634
|
+
|
635
|
+
floor = model.getSurfaceByName(id)
|
636
|
+
kiva = false if floor.empty?
|
637
|
+
next if floor.empty?
|
638
|
+
floor = floor.get
|
639
|
+
construction = floor.construction
|
640
|
+
kiva = false if construction.empty?
|
641
|
+
next if construction.empty?
|
642
|
+
construction = construction.get
|
643
|
+
floor.setAdjacentFoundation(floors[id][:foundation])
|
644
|
+
floor.setConstruction(construction)
|
645
|
+
ep = floors[id][:exposed]
|
646
|
+
per = floor.surfacePropertyExposedFoundationPerimeter
|
647
|
+
|
648
|
+
if per.empty?
|
649
|
+
per = floor.createSurfacePropertyExposedFoundationPerimeter(arg, ep)
|
650
|
+
else
|
651
|
+
per = per.get
|
652
|
+
end
|
653
|
+
|
654
|
+
kiva = false unless per.respond_to?(:totalExposedPerimeter)
|
655
|
+
next unless per.respond_to?(:totalExposedPerimeter)
|
656
|
+
|
657
|
+
perimeter = per.totalExposedPerimeter
|
658
|
+
kiva = false if perimeter.empty?
|
659
|
+
next if perimeter.empty?
|
660
|
+
perimeter = perimeter.get
|
661
|
+
|
662
|
+
if ep < 0.001
|
663
|
+
ok = per.setTotalExposedPerimeter(0.000)
|
664
|
+
ok = per.setTotalExposedPerimeter(0.001) unless ok
|
665
|
+
kiva = false unless ok
|
666
|
+
next unless ok
|
667
|
+
elsif (perimeter - ep).abs > TOL
|
668
|
+
ok = per.setTotalExposedPerimeter(ep)
|
669
|
+
kiva = false unless ok
|
670
|
+
next unless ok
|
671
|
+
|
672
|
+
# Generic 1" XPS insulation for exposed perimeter.
|
673
|
+
xps25 = model.getStandardOpaqueMaterialByName("XPS 25mm")
|
674
|
+
|
675
|
+
if xps25.empty?
|
676
|
+
xps25 = OpenStudio::Model::StandardOpaqueMaterial.new(model)
|
677
|
+
xps25.setName("XPS 25mm")
|
678
|
+
xps25.setRoughness("Rough")
|
679
|
+
xps25.setThickness(0.0254)
|
680
|
+
xps25.setConductivity(0.029)
|
681
|
+
xps25.setDensity(28)
|
682
|
+
xps25.setSpecificHeat(1450)
|
683
|
+
xps25.setThermalAbsorptance(0.9)
|
684
|
+
xps25.setSolarAbsorptance(0.7)
|
685
|
+
else
|
686
|
+
xps25 = xps25.get
|
687
|
+
end
|
688
|
+
|
689
|
+
floors[id][:foundation].setInteriorHorizontalInsulationMaterial(xps25)
|
690
|
+
floors[id][:foundation].setInteriorHorizontalInsulationWidth(0.6)
|
691
|
+
end
|
692
|
+
end
|
693
|
+
end
|
694
|
+
|
695
|
+
walls.each do |i, wall|
|
696
|
+
next unless wall.key?(:kiva)
|
697
|
+
id = walls[i][:kiva]
|
698
|
+
next unless floors.key?(id)
|
699
|
+
next unless floors[id].key?(:foundation)
|
700
|
+
mur = model.getSurfaceByName(i) # locate OpenStudio wall
|
701
|
+
kiva = false if mur.empty?
|
702
|
+
next if mur.empty?
|
703
|
+
mur = mur.get
|
704
|
+
construction = mur.construction
|
705
|
+
kiva = false if construction.empty?
|
706
|
+
next if construction.empty?
|
707
|
+
construction = construction.get
|
708
|
+
mur.setAdjacentFoundation(floors[id][:foundation])
|
709
|
+
mur.setConstruction(construction)
|
710
|
+
end
|
711
|
+
|
712
|
+
kiva
|
713
|
+
end
|
714
|
+
end
|