tbd 3.0.0

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