tbd 3.0.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|