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.
Files changed (45) hide show
  1. checksums.yaml +7 -0
  2. data/.gitattributes +3 -0
  3. data/.github/workflows/pull_request.yml +72 -0
  4. data/.gitignore +23 -0
  5. data/.rspec +3 -0
  6. data/Gemfile +3 -0
  7. data/LICENSE.md +21 -0
  8. data/README.md +154 -0
  9. data/Rakefile +60 -0
  10. data/json/midrise.json +64 -0
  11. data/json/tbd_5ZoneNoHVAC.json +19 -0
  12. data/json/tbd_5ZoneNoHVAC_btap.json +91 -0
  13. data/json/tbd_seb_n2.json +41 -0
  14. data/json/tbd_seb_n4.json +57 -0
  15. data/json/tbd_warehouse10.json +24 -0
  16. data/json/tbd_warehouse5.json +37 -0
  17. data/lib/measures/tbd/LICENSE.md +21 -0
  18. data/lib/measures/tbd/README.md +136 -0
  19. data/lib/measures/tbd/README.md.erb +42 -0
  20. data/lib/measures/tbd/docs/.gitkeep +1 -0
  21. data/lib/measures/tbd/measure.rb +327 -0
  22. data/lib/measures/tbd/measure.xml +460 -0
  23. data/lib/measures/tbd/resources/geo.rb +714 -0
  24. data/lib/measures/tbd/resources/geometry.rb +351 -0
  25. data/lib/measures/tbd/resources/model.rb +1431 -0
  26. data/lib/measures/tbd/resources/oslog.rb +381 -0
  27. data/lib/measures/tbd/resources/psi.rb +2229 -0
  28. data/lib/measures/tbd/resources/tbd.rb +55 -0
  29. data/lib/measures/tbd/resources/transformation.rb +121 -0
  30. data/lib/measures/tbd/resources/ua.rb +986 -0
  31. data/lib/measures/tbd/resources/utils.rb +1636 -0
  32. data/lib/measures/tbd/resources/version.rb +3 -0
  33. data/lib/measures/tbd/tests/tbd_full_PSI.json +17 -0
  34. data/lib/measures/tbd/tests/tbd_tests.rb +222 -0
  35. data/lib/tbd/geo.rb +714 -0
  36. data/lib/tbd/psi.rb +2229 -0
  37. data/lib/tbd/ua.rb +986 -0
  38. data/lib/tbd/version.rb +25 -0
  39. data/lib/tbd.rb +93 -0
  40. data/sponsors/canada.png +0 -0
  41. data/sponsors/quebec.png +0 -0
  42. data/tbd.gemspec +43 -0
  43. data/tbd.schema.json +571 -0
  44. data/v291_MacOS.md +110 -0
  45. 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