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
@@ -0,0 +1,2229 @@
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
+ # Sources for thermal bridge types and default KHI & PSI values/sets:
25
+ #
26
+ # a) BETBG = Building Envelope Thermal Bridging Guide v1.4 (or higher):
27
+ #
28
+ # www.bchydro.com/content/dam/BCHydro/customer-portal/documents/power-smart/
29
+ # business/programs/BETB-Building-Envelope-Thermal-Bridging-Guide-v1-4.pdf
30
+ #
31
+ # b) ISO 14683 (Appendix C): www.iso.org/standard/65706.html
32
+ #
33
+ # c) NECB-QC = Québec's energy code for new commercial buildings:
34
+ #
35
+ # www2.publicationsduquebec.gouv.qc.ca/dynamicSearch/
36
+ # telecharge.php?type=1&file=72541.pdf
37
+ #
38
+ # www.rbq.gouv.qc.ca/domaines-dintervention/efficacite-energetique/
39
+ # la-formation/autres-batiments-outils-educatifs.html
40
+
41
+ ##
42
+ # Library of point thermal bridges (e.g. columns). Each key:value entry
43
+ # requires a unique identifier e.g. "poor (BETBG)" and a KHI-value in W/K.
44
+ class KHI
45
+ # @return [Hash] KHI library
46
+ attr_reader :point
47
+
48
+ ##
49
+ # Construct a new KHI library (with defaults).
50
+ def initialize
51
+ @point = {}
52
+
53
+ # The following are defaults. Users may edit these defaults,
54
+ # append new key:value pairs, or even read-in other pairs on file.
55
+ # Units are in W/K.
56
+ @point["poor (BETBG)" ] = 0.900 # detail 5.7.2 BETBG
57
+ @point["regular (BETBG)" ] = 0.500 # detail 5.7.4 BETBG
58
+ @point["efficient (BETBG)" ] = 0.150 # detail 5.7.3 BETBG
59
+ @point["code (Quebec)" ] = 0.500 # art. 3.3.1.3. NECB-QC
60
+ @point["uncompliant (Quebec)" ] = 1.000 # Guide
61
+ @point["(non thermal bridging)"] = 0.000
62
+ end
63
+
64
+ ##
65
+ # Append a new KHI entry, based on a TBD JSON-formatted KHI object (requires
66
+ # a valid, unique :id key and valid :point value).
67
+ #
68
+ # @param k [Hash] a new KHI entry
69
+ #
70
+ # @return [Bool] true if successfully appended
71
+ # @return [Bool] false if invalid input
72
+ def append(k = {})
73
+ mth = "TBD::#{__callee__}"
74
+ a = false
75
+
76
+ return TBD.mismatch("KHI", k, Hash, mth, DBG, a) unless k.is_a?(Hash)
77
+ return TBD.hashkey("KHI id", k, :id, mth, DBG, a) unless k.key?(:id)
78
+ return TBD.hashkey("KHI pt", k, :point, mth, DBG, a) unless k.key?(:point)
79
+
80
+ if @point.key?(k[:id])
81
+ TBD.log(ERR, "Skipping '#{k[:id]}': existing KHI entry (#{mth})")
82
+ return false
83
+ end
84
+
85
+ @point[k[:id]] = k[:point]
86
+
87
+ true
88
+ end
89
+ end
90
+
91
+ ##
92
+ # Library of linear thermal bridges (e.g. corners, balconies). Each key:value
93
+ # entry requires a unique identifier e.g. "poor (BETBG)" and a (partial or
94
+ # complete) set of PSI-values in W/K per linear meter.
95
+ class PSI
96
+ # @return [Hash] PSI set
97
+ attr_reader :set
98
+
99
+ # @return [Hash] shorthand listing of PSI types in a set
100
+ attr_reader :has
101
+
102
+ # @return [Hash] shorthand listing of PSI values in a set
103
+ attr_reader :val
104
+
105
+ ##
106
+ # Construct a new PSI library (with defaults)
107
+ def initialize
108
+ @set = {}
109
+ @has = {}
110
+ @val = {}
111
+
112
+ # The following are default PSI values (* published, ** calculated). Users
113
+ # may edit these sets, add new sets here, or read-in custom sets from a
114
+ # TBD JSON input file. PSI units are in W/K per linear meter. The spandrel
115
+ # sets are added as practical suggestions in early design stages.
116
+
117
+ # Convex vs concave PSI adjustments may be warranted if there is a
118
+ # mismatch between dimensioning conventions (interior vs exterior) used
119
+ # for the OpenStudio model (OSM) vs published PSI data. For instance, the
120
+ # BETBG data reflects an interior dimensioning convention, while ISO
121
+ # 14683 reports PSI values for both conventions. The following may be
122
+ # used (with caution) to adjust BETBG PSI values for convex corners when
123
+ # using outside dimensions for an OSM.
124
+ #
125
+ # PSIe = PSIi + U * 2(Li-Le), where:
126
+ # PSIe = adjusted PSI (W/K per m)
127
+ # PSIi = initial published PSI (W/K per m)
128
+ # U = average clear field U-factor of adjacent walls (W/m2.K)
129
+ # Li = from interior corner to edge of "zone of influence" (m)
130
+ # Le = from exterior corner to edge of "zone of influence" (m)
131
+ #
132
+ # Li-Le = wall thickness e.g., -0.25m (negative here as Li < Le)
133
+ @set["poor (BETBG)"] =
134
+ {
135
+ rimjoist: 1.000, # *
136
+ parapet: 0.800, # *
137
+ fenestration: 0.500, # *
138
+ corner: 0.850, # *
139
+ balcony: 1.000, # *
140
+ party: 0.850, # *
141
+ grade: 0.850, # *
142
+ joint: 0.300, # *
143
+ transition: 0.000
144
+ }.freeze # based on INTERIOR dimensions (p.15 BETBG)
145
+ self.gen("poor (BETBG)")
146
+
147
+ @set["regular (BETBG)"] =
148
+ {
149
+ rimjoist: 0.500, # *
150
+ parapet: 0.450, # *
151
+ fenestration: 0.350, # *
152
+ corner: 0.450, # *
153
+ balcony: 0.500, # *
154
+ party: 0.450, # *
155
+ grade: 0.450, # *
156
+ joint: 0.200, # *
157
+ transition: 0.000
158
+ }.freeze # based on INTERIOR dimensions (p.15 BETBG)
159
+ self.gen("regular (BETBG)")
160
+
161
+ @set["efficient (BETBG)"] =
162
+ {
163
+ rimjoist: 0.200, # *
164
+ parapet: 0.200, # *
165
+ fenestration: 0.200, # *
166
+ corner: 0.200, # *
167
+ balcony: 0.200, # *
168
+ party: 0.200, # *
169
+ grade: 0.200, # *
170
+ joint: 0.100, # *
171
+ transition: 0.000
172
+ }.freeze # based on INTERIOR dimensions (p.15 BETBG)
173
+ self.gen("efficient (BETBG)")
174
+
175
+ @set["spandrel (BETBG)"] =
176
+ {
177
+ rimjoist: 0.615, # * Detail 1.2.1
178
+ parapet: 1.000, # * Detail 1.3.2
179
+ fenestration: 0.000, # * ... generally part of clear-field RSi
180
+ corner: 0.425, # * Detail 1.4.1
181
+ balcony: 1.110, # * Detail 8.1.9/9.1.6
182
+ party: 0.990, # ** ... similar to parapet/balcony
183
+ grade: 0.880, # * Detail 2.5.1
184
+ joint: 0.500, # * Detail 3.3.2
185
+ transition: 0.000
186
+ }.freeze # "conventional", closer to window wall spandrels
187
+ self.gen("spandrel (BETBG)")
188
+
189
+ @set["spandrel HP (BETBG)"] =
190
+ {
191
+ rimjoist: 0.170, # * Detail 1.2.7
192
+ parapet: 0.660, # * Detail 1.3.2
193
+ fenestration: 0.000, # * ... generally part of clear-field RSi
194
+ corner: 0.200, # * Detail 1.4.2
195
+ balcony: 0.400, # * Detail 9.1.15
196
+ party: 0.500, # ** ... similar to parapet/balcony
197
+ grade: 0.880, # * Detail 2.5.1
198
+ joint: 0.140, # * Detail 7.4.2
199
+ transition: 0.000
200
+ }.freeze # "good/high performance" curtainwall spandrels
201
+ self.gen("spandrel HP (BETBG)")
202
+
203
+ @set["code (Quebec)"] = # NECB-QC (code-compliant) defaults:
204
+ {
205
+ rimjoist: 0.300, # *
206
+ parapet: 0.325, # *
207
+ fenestration: 0.200, # *
208
+ corner: 0.300, # ** "regular (BETBG)", adj. for ext. dimensions
209
+ balcony: 0.500, # *
210
+ party: 0.450, # *
211
+ grade: 0.450, # *
212
+ joint: 0.200, # *
213
+ transition: 0.000
214
+ }.freeze # based on EXTERIOR dimensions (art. 3.1.1.6)
215
+ self.gen("code (Quebec)")
216
+
217
+ @set["uncompliant (Quebec)"] = # NECB-QC (non-code-compliant) defaults:
218
+ {
219
+ rimjoist: 0.850, # *
220
+ parapet: 0.800, # *
221
+ fenestration: 0.500, # *
222
+ corner: 0.850, # ** ... not stated
223
+ balcony: 1.000, # *
224
+ party: 0.850, # *
225
+ grade: 0.850, # *
226
+ joint: 0.500, # *
227
+ transition: 0.000
228
+ }.freeze # based on EXTERIOR dimensions (art. 3.1.1.6)
229
+ self.gen("uncompliant (Quebec)")
230
+
231
+ @set["(non thermal bridging)"] = # ... would not derate surfaces:
232
+ {
233
+ rimjoist: 0.000,
234
+ parapet: 0.000,
235
+ fenestration: 0.000,
236
+ corner: 0.000,
237
+ balcony: 0.000,
238
+ party: 0.000,
239
+ grade: 0.000,
240
+ joint: 0.000,
241
+ transition: 0.000
242
+ }.freeze
243
+ self.gen("(non thermal bridging)")
244
+ end
245
+
246
+ ##
247
+ # Generate PSI set shorthand listings (requires a valid id).
248
+ #
249
+ # @param id [String] a PSI set identifier
250
+ #
251
+ # @return [Bool] true if successful in generating PSI set shorthands
252
+ # @return [Bool] false if invalid input
253
+ def gen(id = "")
254
+ mth = "TBD::#{__callee__}"
255
+ a = false
256
+
257
+ return TBD.mismatch("id", id, String, mth, DBG, a) unless id.is_a?(String)
258
+ return TBD.hashkey(id, @set, id, mth, ERR, a) unless @set.key?(id)
259
+
260
+ h = {} # true/false if PSI set has PSI type
261
+ h[:joint ] = @set[id].key?(:joint )
262
+ h[:transition ] = @set[id].key?(:transition )
263
+ h[:fenestration ] = @set[id].key?(:fenestration )
264
+ h[:head ] = @set[id].key?(:head )
265
+ h[:headconcave ] = @set[id].key?(:headconcave )
266
+ h[:headconvex ] = @set[id].key?(:headconvex )
267
+ h[:sill ] = @set[id].key?(:sill )
268
+ h[:sillconcave ] = @set[id].key?(:sillconcave )
269
+ h[:sillconvex ] = @set[id].key?(:sillconvex )
270
+ h[:jamb ] = @set[id].key?(:jamb )
271
+ h[:jambconcave ] = @set[id].key?(:jambconcave )
272
+ h[:jambconvex ] = @set[id].key?(:jambconvex )
273
+ h[:corner ] = @set[id].key?(:corner )
274
+ h[:cornerconcave ] = @set[id].key?(:cornerconcave )
275
+ h[:cornerconvex ] = @set[id].key?(:cornerconvex )
276
+ h[:parapet ] = @set[id].key?(:parapet )
277
+ h[:partyconcave ] = @set[id].key?(:parapetconcave )
278
+ h[:parapetconvex ] = @set[id].key?(:parapetconvex )
279
+ h[:party ] = @set[id].key?(:party )
280
+ h[:partyconcave ] = @set[id].key?(:partyconcave )
281
+ h[:partyconvex ] = @set[id].key?(:partyconvex )
282
+ h[:grade ] = @set[id].key?(:grade )
283
+ h[:gradeconcave ] = @set[id].key?(:gradeconcave )
284
+ h[:gradeconvex ] = @set[id].key?(:gradeconvex )
285
+ h[:balcony ] = @set[id].key?(:balcony )
286
+ h[:balconyconcave ] = @set[id].key?(:balconyconcave )
287
+ h[:balconyconvex ] = @set[id].key?(:balconyconvex )
288
+ h[:rimjoist ] = @set[id].key?(:rimjoist )
289
+ h[:rimjoistconcave] = @set[id].key?(:rimjoistconcave)
290
+ h[:rimjoistconvex ] = @set[id].key?(:rimjoistconvex )
291
+ @has[id] = h
292
+
293
+ v = {} # PSI-value (W/K per linear meter)
294
+ v[:joint ] = 0; v[:transition ] = 0; v[:fenestration ] = 0
295
+ v[:head ] = 0; v[:headconcave ] = 0; v[:headconvex ] = 0
296
+ v[:sill ] = 0; v[:sillconcave ] = 0; v[:sillconvex ] = 0
297
+ v[:jamb ] = 0; v[:jambconcave ] = 0; v[:jambconvex ] = 0
298
+ v[:corner ] = 0; v[:cornerconcave ] = 0; v[:cornerconvex ] = 0
299
+ v[:parapet ] = 0; v[:parapetconcave ] = 0; v[:parapetconvex ] = 0
300
+ v[:party ] = 0; v[:partyconcave ] = 0; v[:partyconvex ] = 0
301
+ v[:grade ] = 0; v[:gradeconcave ] = 0; v[:gradeconvex ] = 0
302
+ v[:balcony ] = 0; v[:balconyconcave ] = 0; v[:balconyconvex ] = 0
303
+ v[:rimjoist] = 0; v[:rimjoistconcave] = 0; v[:rimjoistconvex] = 0
304
+
305
+ v[:joint ] = @set[id][:joint ] if h[:joint ]
306
+ v[:transition ] = @set[id][:transition ] if h[:transition ]
307
+ v[:fenestration ] = @set[id][:fenestration ] if h[:fenestration ]
308
+ v[:head ] = @set[id][:fenestration ] if h[:fenestration ]
309
+ v[:headconcave ] = @set[id][:fenestration ] if h[:fenestration ]
310
+ v[:headconvex ] = @set[id][:fenestration ] if h[:fenestration ]
311
+ v[:sill ] = @set[id][:fenestration ] if h[:fenestration ]
312
+ v[:sillconcave ] = @set[id][:fenestration ] if h[:fenestration ]
313
+ v[:sillconvex ] = @set[id][:fenestration ] if h[:fenestration ]
314
+ v[:jamb ] = @set[id][:fenestration ] if h[:fenestration ]
315
+ v[:jambconcave ] = @set[id][:fenestration ] if h[:fenestration ]
316
+ v[:jambconvex ] = @set[id][:fenestration ] if h[:fenestration ]
317
+ v[:head ] = @set[id][:head ] if h[:head ]
318
+ v[:headconcave ] = @set[id][:head ] if h[:head ]
319
+ v[:headconvex ] = @set[id][:head ] if h[:head ]
320
+ v[:sill ] = @set[id][:sill ] if h[:sill ]
321
+ v[:sillconcave ] = @set[id][:sill ] if h[:sill ]
322
+ v[:sillconvex ] = @set[id][:sill ] if h[:sill ]
323
+ v[:jamb ] = @set[id][:jamb ] if h[:jamb ]
324
+ v[:jambconcave ] = @set[id][:jamb ] if h[:jamb ]
325
+ v[:jambconvex ] = @set[id][:jamb ] if h[:jamb ]
326
+ v[:headconcave ] = @set[id][:headconcave ] if h[:headconcave ]
327
+ v[:headconvex ] = @set[id][:headconvex ] if h[:headconvex ]
328
+ v[:sillconcave ] = @set[id][:sillconcave ] if h[:sillconcave ]
329
+ v[:sillconvex ] = @set[id][:sillconvex ] if h[:sillconvex ]
330
+ v[:jambconcave ] = @set[id][:jambconcave ] if h[:jambconcave ]
331
+ v[:jambconvex ] = @set[id][:jambconvex ] if h[:jambconvex ]
332
+ v[:corner ] = @set[id][:corner ] if h[:corner ]
333
+ v[:cornerconcave ] = @set[id][:corner ] if h[:corner ]
334
+ v[:cornerconvex ] = @set[id][:corner ] if h[:corner ]
335
+ v[:cornerconcave ] = @set[id][:cornerconcave ] if h[:cornerconcave ]
336
+ v[:cornerconvex ] = @set[id][:cornerconvex ] if h[:cornerconvex ]
337
+ v[:parapet ] = @set[id][:parapet ] if h[:parapet ]
338
+ v[:parapetconcave ] = @set[id][:parapet ] if h[:parapet ]
339
+ v[:parapetconvex ] = @set[id][:parapet ] if h[:parapet ]
340
+ v[:parapetconcave ] = @set[id][:parapetconcave ] if h[:parapetconcave ]
341
+ v[:parapetconvex ] = @set[id][:parapetconvex ] if h[:parapetconvex ]
342
+ v[:party ] = @set[id][:party ] if h[:party ]
343
+ v[:partyconcave ] = @set[id][:party ] if h[:party ]
344
+ v[:partyconvex ] = @set[id][:party ] if h[:party ]
345
+ v[:partyconcave ] = @set[id][:partyconcave ] if h[:partyconcave ]
346
+ v[:partyconvex ] = @set[id][:partyconvex ] if h[:partyconvex ]
347
+ v[:grade ] = @set[id][:grade ] if h[:grade ]
348
+ v[:gradeconcave ] = @set[id][:grade ] if h[:grade ]
349
+ v[:gradeconvex ] = @set[id][:grade ] if h[:grade ]
350
+ v[:gradeconcave ] = @set[id][:gradeconcave ] if h[:gradeconcave ]
351
+ v[:gradeconvex ] = @set[id][:gradeconvex ] if h[:gradeconvex ]
352
+ v[:balcony ] = @set[id][:balcony ] if h[:balcony ]
353
+ v[:balconyconcave ] = @set[id][:balcony ] if h[:balcony ]
354
+ v[:balconyconvex ] = @set[id][:balcony ] if h[:balcony ]
355
+ v[:balconyconcave ] = @set[id][:balconyconcave ] if h[:balconyconcave ]
356
+ v[:balconyconvex ] = @set[id][:balconyconvex ] if h[:balconyconvex ]
357
+ v[:rimjoist ] = @set[id][:rimjoist ] if h[:rimjoist ]
358
+ v[:rimjoistconcave] = @set[id][:rimjoist ] if h[:rimjoist ]
359
+ v[:rimjoistconvex ] = @set[id][:rimjoist ] if h[:rimjoist ]
360
+ v[:rimjoistconcave] = @set[id][:rimjoistconcave] if h[:rimjoistconcave]
361
+ v[:rimjoistconvex ] = @set[id][:rimjoistconvex ] if h[:rimjoistconvex ]
362
+
363
+ max = [v[:parapetconcave], v[:parapetconvex]].max
364
+ v[:parapet] = max unless @has[:parapet]
365
+ @val[id] = v
366
+
367
+ true
368
+ end
369
+
370
+ ##
371
+ # Append a new PSI set, based on a TBD JSON-formatted PSI set object -
372
+ # requires a valid, unique :id.
373
+ #
374
+ # @param set [Hash] a new PSI set
375
+ #
376
+ # @return [Bool] true if successfully appended
377
+ # @return [Bool] false if invalid input
378
+ def append(set = {})
379
+ mth = "TBD::#{__callee__}"
380
+ a = false
381
+
382
+ return TBD.mismatch("set", set, Hash, mth, DBG, a) unless set.is_a?(Hash)
383
+ return TBD.hashkey("set id", set, :id, mth, DBG, a) unless set.key?(:id)
384
+
385
+ exists = @set.key?(set[:id])
386
+ TBD.log(ERR, "'#{set[:id]}': existing PSI set (#{mth})") if exists
387
+ return false if exists
388
+
389
+ s = {}
390
+ # Most PSI types have concave and convex variants, depending on the polar
391
+ # position of deratable surfaces about an edge-as-thermal-bridge. One
392
+ # exception is :fenestration, which TBD later breaks down into :head,
393
+ # :sill or :jamb edge types. Another exception is a :joint edge: a PSI
394
+ # type that is not autoassigned to an edge (i.e., only via a TBD JSON
395
+ # input file). Finally, transitions are autoassigned by TBD when an edge
396
+ # is "flat", i.e, no noticeable polar angle difference between surfaces.
397
+ s[:rimjoist ] = set[:rimjoist ] if set.key?(:rimjoist )
398
+ s[:rimjoistconcave] = set[:rimjoistconcave] if set.key?(:rimjoistconcave)
399
+ s[:rimjoistconvex ] = set[:rimjoistconvex ] if set.key?(:rimjoistconvex )
400
+ s[:parapet ] = set[:parapet ] if set.key?(:parapet )
401
+ s[:parapetconcave ] = set[:parapetconcave ] if set.key?(:parapetconcave )
402
+ s[:parapetconvex ] = set[:parapetconvex ] if set.key?(:parapetconvex )
403
+ s[:head ] = set[:head ] if set.key?(:head )
404
+ s[:headconcave ] = set[:headconcave ] if set.key?(:headconcave )
405
+ s[:headconvex ] = set[:headconvex ] if set.key?(:headconvex )
406
+ s[:sill ] = set[:sill ] if set.key?(:sill )
407
+ s[:sillconcave ] = set[:sillconcave ] if set.key?(:sillconcave )
408
+ s[:sillconvex ] = set[:sillconvex ] if set.key?(:sillconvex )
409
+ s[:jamb ] = set[:jamb ] if set.key?(:jamb )
410
+ s[:jambconcave ] = set[:jambconcave ] if set.key?(:jambconcave )
411
+ s[:jambconvex ] = set[:jambconvex ] if set.key?(:jambconcave )
412
+ s[:corner ] = set[:corner ] if set.key?(:corner )
413
+ s[:cornerconcave ] = set[:cornerconcave ] if set.key?(:cornerconcave )
414
+ s[:cornerconvex ] = set[:cornerconvex ] if set.key?(:cornerconvex )
415
+ s[:balcony ] = set[:balcony ] if set.key?(:balcony )
416
+ s[:balconyconcave ] = set[:balconyconcave ] if set.key?(:balconyconcave )
417
+ s[:balconyconvex ] = set[:balconyconvex ] if set.key?(:balconyconvex )
418
+ s[:party ] = set[:party ] if set.key?(:party )
419
+ s[:partyconcave ] = set[:partyconcave ] if set.key?(:partyconcave )
420
+ s[:partyconvex ] = set[:partyconvex ] if set.key?(:partyconvex )
421
+ s[:grade ] = set[:grade ] if set.key?(:grade )
422
+ s[:gradeconcave ] = set[:gradeconcave ] if set.key?(:gradeconcave )
423
+ s[:gradeconvex ] = set[:gradeconvex ] if set.key?(:gradeconvex )
424
+ s[:fenestration ] = set[:fenestration ] if set.key?(:fenestration )
425
+ s[:joint ] = set[:joint ] if set.key?(:joint )
426
+ s[:transition ] = set[:transition ] if set.key?(:transition )
427
+
428
+ s[:joint ] = 0.000 unless set.key?(:joint )
429
+ s[:transition ] = 0.000 unless set.key?(:transition )
430
+
431
+ @set[set[:id]] = s
432
+ self.gen(set[:id])
433
+
434
+ true
435
+ end
436
+
437
+ ##
438
+ # Return PSI set shorthands. The return Hash holds 2x keys ... has: a Hash
439
+ # of true/false (values) for any admissible PSI type (keys), and val: a Hash
440
+ # of PSI-values for any admissible PSI type (default: 0.0 W/K per meter).
441
+ #
442
+ # @param id [String] a PSI set identifier
443
+ #
444
+ # @return [Hash] has: Hash of true/false, val: Hash of PSI values
445
+ # @return [Hash] has: empty Hash, val: empty Hash (if invalid/missing set)
446
+ def shorthands(id = "")
447
+ mth = "TBD::#{__callee__}"
448
+ cl = String
449
+ sh = { has: {}, val: {} }
450
+
451
+ return TBD.mismatch("id", id, String, mth, DBG, sh) unless id.is_a?(cl)
452
+ return TBD.hashkey(id, @set, id, mth, ERR, sh) unless @set.key?(id)
453
+ return TBD.hashkey(id, @has, id, mth, ERR, sh) unless @has.key?(id)
454
+ return TBD.hashkey(id, @val, id, mth, ERR, sh) unless @val.key?(id)
455
+
456
+ sh[:has] = @has[id]
457
+ sh[:val] = @val[id]
458
+
459
+ sh
460
+ end
461
+
462
+ ##
463
+ # Validate whether a given PSI set has a complete list of PSI type:values.
464
+ #
465
+ # @param id [String] a PSI set identifier
466
+ #
467
+ # @return [Bool] true if found and is complete
468
+ # @return [Bool] false if invalid input
469
+ def complete?(id = "")
470
+ mth = "TBD::#{__callee__}"
471
+ a = false
472
+
473
+ return TBD.mismatch("id", id, String, mth, DBG, a) unless id.is_a?(String)
474
+ return TBD.hashkey(id, @set, id, mth, ERR, a) unless @set.key?(id)
475
+ return TBD.hashkey(id, @has, id, mth, ERR, a) unless @has.key?(id)
476
+ return TBD.hashkey(id, @val, id, mth, ERR, a) unless @val.key?(id)
477
+
478
+ holes = []
479
+ holes << :head if @has[id][:head ]
480
+ holes << :sill if @has[id][:sill ]
481
+ holes << :jamb if @has[id][:jamb ]
482
+ ok = holes.size == 3
483
+ ok = true if @has[id][:fenestration ]
484
+ return false unless ok
485
+
486
+ corners = []
487
+ corners << :concave if @has[id][:cornerconcave ]
488
+ corners << :convex if @has[id][:cornerconvex ]
489
+ ok = corners.size == 2
490
+ ok = true if @has[id][:corner ]
491
+ return false unless ok
492
+
493
+ parapets = []
494
+ parapets << :concave if @has[id][:parapetconcave]
495
+ parapets << :convex if @has[id][:parapetconvex ]
496
+ ok = parapets.size == 2
497
+ ok = true if @has[id][:parapet ]
498
+ return false unless ok
499
+ return false unless @has[id][:party ]
500
+ return false unless @has[id][:grade ]
501
+ return false unless @has[id][:balcony ]
502
+ return false unless @has[id][:rimjoist ]
503
+
504
+ ok
505
+ end
506
+
507
+ ##
508
+ # Return safe PSI type if missing input from PSI set (based on inheritance).
509
+ #
510
+ # @param id [String] a PSI set identifier
511
+ # @param type [Symbol] a PSI type, e.g. :rimjoistconcave
512
+ #
513
+ # @return [Symbol] safe PSI type
514
+ # @return [Nil] if invalid input or no safe PSI type found
515
+ def safe(id = "", type = nil)
516
+ mth = "TBD::#{__callee__}"
517
+ cl1 = String
518
+ cl2 = Symbol
519
+
520
+ return TBD.mismatch("id", id, cl1, mth) unless id.is_a?(cl1)
521
+ return TBD.mismatch("type", type, cl2, mth, ERR) unless type.is_a?(cl2)
522
+ return TBD.hashkey(id, @set, id, mth, ERR) unless @set.key?(id)
523
+ return TBD.hashkey(id, @has, id, mth, ERR) unless @has.key?(id)
524
+
525
+ safer = type
526
+
527
+ unless @has[id][safer]
528
+ concave = type.to_s.include?("concave")
529
+ convex = type.to_s.include?("convex")
530
+ safer = type.to_s.chomp("concave").to_sym if concave
531
+ safer = type.to_s.chomp("convex").to_sym if convex
532
+
533
+ unless @has[id][safer]
534
+ safer = :fenestration if safer == :head
535
+ safer = :fenestration if safer == :sill
536
+ safer = :fenestration if safer == :jamb
537
+ end
538
+ end
539
+
540
+ return safer if @has[id][safer]
541
+
542
+ nil
543
+ end
544
+ end
545
+
546
+ ##
547
+ # Process TBD JSON inputs, after TBD has processed OpenStudio model variables
548
+ # and retrieved corresponding Topolys model surface/edge properties. TBD user
549
+ # inputs allow customization of default assumptions and inferred values.
550
+ # If successful, "edges" (input) may inherit additional properties, e.g.:
551
+ # edge-specific PSI set (defined in TBD JSON file), edge-specific PSI type
552
+ # (e.g. "corner", defined in TBD JSON file), project-wide PSI set (if absent
553
+ # from TBD JSON file).
554
+ #
555
+ # @param s [Hash] preprocessed TBD surfaces
556
+ # @param e [Hash] preprocessed TBD edges
557
+ # @param argh [Hash] arguments
558
+ #
559
+ # @return [Hash] io: JSON inputs (Hash), psi:/khi: new (enriched) sets (Hash)
560
+ # @return [Hash] io: empty Hash if invalid input
561
+ def inputs(s = {}, e = {}, argh = {})
562
+ mth = "TBD::#{__callee__}"
563
+ opt = :option
564
+ ipt = { io: {}, psi: PSI.new, khi: KHI.new }
565
+ io = {}
566
+
567
+ return mismatch("s", s, Hash, mth, DBG, ipt) unless s.is_a?(Hash)
568
+ return mismatch("e", s, Hash, mth, DBG, ipt) unless e.is_a?(Hash)
569
+ return mismatch("argh", s, Hash, mth, DBG, ipt) unless argh.is_a?(Hash)
570
+ return hashkey("argh", argh, opt, mth, DBG, ipt) unless argh.key?(opt)
571
+
572
+ argh[:io_path] = nil unless argh.key?(:io_path)
573
+ argh[:schema_path] = nil unless argh.key?(:schema_path)
574
+ pth = argh[:io_path]
575
+ sch = argh[:schema_path]
576
+
577
+ if pth
578
+ return empty("JSON file", mth, FTL, ipt) unless File.size?(pth)
579
+ io = File.read(pth)
580
+ io = JSON.parse(io, symbolize_names: true)
581
+ return mismatch("io", io, Hash, mth, FTL, ipt) unless io.is_a?(Hash)
582
+
583
+ # Schema validation is not yet supported in the OpenStudio Application.
584
+ # We nonetheless recommend that users rely on the json-schema gem, or an
585
+ # online linter, prior to using TBD. The following checks focus on content
586
+ # - ignoring bad JSON input otherwise caught via JSON validation.
587
+ #
588
+ # A side note: JSON validation relies on case-senitive string comparisons
589
+ # (e.g. OpenStudio space or surface names, vs corresponding TBD JSON
590
+ # identifiers). So "Space-1" doesn't match "SPACE-1" - head's up.
591
+ if sch
592
+ require "json-schema"
593
+
594
+ return invalid("JSON schema", mth, 0, FTL, ipt) unless File.exist?(sch)
595
+ return empty("JSON schema", mth, FTL, ipt) if File.zero?(sch)
596
+ schema = File.read(sch)
597
+ schema = JSON.parse(schema, symbolize_names: true)
598
+ valid = JSON::Validator.validate!(schema, io)
599
+ return invalid("JSON schema validation", mth, 0, FTL, ipt) unless valid
600
+ end
601
+
602
+ # Append JSON entries to library of linear & point thermal bridges.
603
+ io[:psis].each { |psi| ipt[:psi].append(psi) } if io.key?(:psis)
604
+ io[:khis].each { |khi| ipt[:khi].append(khi) } if io.key?(:khis)
605
+
606
+ # JSON-defined or user-selected, building PSI set must be complete/valid.
607
+ io[:building] = { psi: argh[opt] } unless io.key?(:building)
608
+ bdg = io[:building]
609
+ ok = bdg.key?(:psi)
610
+ return hashkey("Building PSI", bdg, :psi, mth, FTL, ipt) unless ok
611
+ ok = ipt[:psi].complete?(bdg[:psi])
612
+ return invalid("Complete building PSI", mth, 0, FTL, ipt) unless ok
613
+
614
+ # Validate remaining (optional) JSON entries.
615
+ [:stories, :spacetypes, :spaces].each do |types|
616
+ key = :story
617
+ key = :stype if types == :spacetypes
618
+ key = :space if types == :spaces
619
+
620
+ if io.key?(types)
621
+ io[types].each do |type|
622
+ next unless type.key?(:psi)
623
+ next unless type.key?(:id)
624
+ s1 = "JSON/OSM '#{type[:id]}' (#{mth})"
625
+ s2 = "JSON/PSI '#{type[:id]}' set (#{mth})"
626
+ match = false
627
+
628
+ s.values.each do |props| # TBD model surface linked to type?
629
+ break if match
630
+ next unless props.key?(key)
631
+ match = type[:id] == props[key].nameString
632
+ end
633
+
634
+ log(ERR, s1) unless match
635
+ log(ERR, s2) unless ipt[:psi].set.key?(type[:psi])
636
+ end
637
+ end
638
+ end
639
+
640
+ if io.key?(:surfaces)
641
+ io[:surfaces].each do |surface|
642
+ next unless surface.key?(:id)
643
+ s1 = "JSON/OSM surface '#{surface[:id]}' (#{mth})"
644
+ log(ERR, s1) unless s.key?(surface[:id])
645
+
646
+ # surfaces can OPTIONALLY hold custom PSI sets and/or KHI data
647
+ if surface.key?(:psi)
648
+ s2 = "JSON/OSM surface/set '#{surface[:id]}' (#{mth})"
649
+ log(ERR, s2) unless ipt[:psi].set.key?(surface[:psi])
650
+ end
651
+
652
+ if surface.key?(:khis)
653
+ surface[:khis].each do |khi|
654
+ next unless khi.key?(:id)
655
+ s3 = "JSON/KHI surface '#{surface[:id]}' '#{khi[:id]}' (#{mth})"
656
+ log(ERR, s3) unless ipt[:khi].point.key?(khi[:id])
657
+ end
658
+ end
659
+ end
660
+ end
661
+
662
+ if io.key?(:subsurfaces)
663
+ io[:subsurfaces].each do |sub|
664
+ next unless sub.key?(:id)
665
+ next unless sub.key?(:usi)
666
+ match = false
667
+
668
+ s.each do |id, surface|
669
+ break if match
670
+
671
+ [:windows, :doors, :skylights].each do |holes|
672
+ if surface.key?(holes)
673
+ surface[holes].keys.each do |id|
674
+ break if match
675
+ match = sub[:id] == id
676
+ end
677
+ end
678
+ end
679
+ end
680
+
681
+ log(ERR, "JSON/OSM subsurface '#{sub[:id]}' (#{mth})") unless match
682
+ end
683
+ end
684
+
685
+ if io.key?(:edges)
686
+ io[:edges].each do |edge|
687
+ next unless edge.key?(:type)
688
+ next unless edge.key?(:surfaces)
689
+ surfaces = edge[:surfaces]
690
+ type = edge[:type].to_sym
691
+ safer = ipt[:psi].safe(bdg[:psi], type) # fallback
692
+ log(ERR, "Skipping invalid edge PSI '#{type}' (#{mth})") unless safer
693
+ next unless safer
694
+ valid = true
695
+
696
+ surfaces.each do |surface| # TBD edge's surfaces on file
697
+ e.values.each do |ee| # TBD edges in memory
698
+ break unless valid # if previous anomaly detected
699
+ next if ee.key?(:io_type) # validated from previous loop
700
+ next unless ee.key?(:surfaces)
701
+ surfs = ee[:surfaces]
702
+ next unless surfs.key?(surface)
703
+
704
+ # An edge on file is valid if ALL of its listed surfaces together
705
+ # connect at least one or more TBD/Topolys model edges in memory.
706
+ # Each of the latter may connect e.g. 3x TBD/Topolys surfaces,
707
+ # but the list of surfaces on file may be shorter, e.g. only 2x.
708
+ match = true
709
+ surfaces.each { |id| match = false unless surfs.key?(id) }
710
+ next unless match
711
+
712
+ if edge.key?(:length) # optional
713
+ next unless (ee[:length] - edge[:length]).abs < TOL
714
+ end
715
+
716
+ # Optionally, edge coordinates may narrow down potential matches.
717
+ if edge.key?(:v0x) || edge.key?(:v0y) || edge.key?(:v0z) ||
718
+ edge.key?(:v1x) || edge.key?(:v1y) || edge.key?(:v1z)
719
+
720
+ unless edge.key?(:v0x) && edge.key?(:v0y) && edge.key?(:v0z) &&
721
+ edge.key?(:v1x) && edge.key?(:v1y) && edge.key?(:v1z)
722
+
723
+ log(ERR, "Mismatch '#{surface}' edge vertices (#{mth})")
724
+ valid = false
725
+ next
726
+ end
727
+
728
+ e1 = {}
729
+ e2 = {}
730
+ e1[:v0] = Topolys::Point3D.new(edge[:v0x].to_f,
731
+ edge[:v0y].to_f,
732
+ edge[:v0z].to_f)
733
+ e1[:v1] = Topolys::Point3D.new(edge[:v1x].to_f,
734
+ edge[:v1y].to_f,
735
+ edge[:v1z].to_f)
736
+ e2[:v0] = ee[:v0].point
737
+ e2[:v1] = ee[:v1].point
738
+ next unless matches?(e1, e2)
739
+ end
740
+
741
+ if edge.key?(:psi) # optional
742
+ set = edge[:psi]
743
+
744
+ if ipt[:psi].set.key?(set)
745
+ saferr = ipt[:psi].safe(set, type)
746
+ ee[:io_set ] = set if saferr
747
+ ee[:io_type] = type if saferr
748
+ log(ERR, "Invalid '#{set}': '#{type}' (#{mth})") unless saferr
749
+ valid = false unless saferr
750
+ else
751
+ log(ERR, "Missing edge PSI '#{set}' (#{mth})")
752
+ valid = false
753
+ end
754
+ else
755
+ ee[:io_type] = type # success: matching edge - setting edge type
756
+ end
757
+ end
758
+ end
759
+ end
760
+ end
761
+ else
762
+ # No (optional) user-defined TBD JSON input file. In such cases, provided
763
+ # argh[:option] must refer to a valid PSI set. If valid, all edges inherit
764
+ # a default PSI set (without KHI entries).
765
+ ok = ipt[:psi].complete?(argh[opt])
766
+ io[:building] = { psi: argh[opt] } if ok
767
+ log(FTL, "Incomplete building PSI set '#{argh[opt]}' (#{mth})") unless ok
768
+ return ipt unless ok
769
+ end
770
+
771
+ ipt[:io] = io
772
+
773
+ ipt
774
+ end
775
+
776
+ ##
777
+ # Thermally derate insulating material within construction.
778
+ #
779
+ # @param model [OpenStudio::Model::Model] a model
780
+ # @param id [String] surface identifier
781
+ # @param surface [Hash] a TBD surface
782
+ # @param lc [OpenStudio::Model::LayeredConstruction] a layered construction
783
+ #
784
+ # @return [OpenStudio::Model::Material] derated (cloned) material
785
+ # @return [NilClass] if invalid input
786
+ def derate(model = nil, id = "", s = {}, lc = nil)
787
+ mth = "TBD::#{__callee__}"
788
+ m = nil
789
+ k1 = :heatloss
790
+ k2 = :ltype
791
+ k3 = :construction
792
+ k4 = :index
793
+ cl1 = OpenStudio::Model::Model
794
+ cl2 = OpenStudio::Model::LayeredConstruction
795
+ cl3 = Numeric
796
+ cl4 = Symbol
797
+ cl5 = Integer
798
+
799
+ return mismatch("model", model, cl, mth) unless model.is_a?(cl1)
800
+ return mismatch("id", id, String, mth) unless id.is_a?(String)
801
+ return mismatch(id, s, Hash, mth) unless s.is_a?(Hash)
802
+ return mismatch("lc", lc, Hash, mth) unless lc.is_a?(cl2)
803
+ return hashkey("'#{id}' W/K", s, k1, mth) unless s.key?(k1)
804
+ return invalid("'#{id}' W/K", mth, 3) unless s[k1]
805
+ return mismatch("'#{id}' W/K", s[k1], cl3, mth) unless s[k1].is_a?(cl3)
806
+ return zero("'#{id}' W/K", mth, WRN) if s[k1].abs < TOL
807
+ return hashkey("'#{id}' m2", s, :net, mth) unless s.key?(:net)
808
+ return invalid("'#{id}' m2", mth, 3) unless s[:net]
809
+ return mismatch("'#{id}' m2", s[:net], cl3, mth) unless s[:net].is_a?(cl3)
810
+ return zero("'#{id}' m2", mth, WRN) if s[:net].abs < TOL
811
+ return hashkey("'#{id}' type", s, k2, mth) unless s.key?(k2)
812
+ return invalid("'#{id}' type", mth, 3) unless s[k2]
813
+ return mismatch("'#{id}' type", s[k2], cl4, mth) unless s[k2].is_a?(cl4)
814
+
815
+ ok = s[k2] == :massless || s[k2] == :standard
816
+
817
+ return invalid("'#{id}' type", mth, 3) unless ok
818
+ return hashkey("'#{id}' construction", s, k3, mth) unless s.key?(k3)
819
+ return hashkey("'#{id}' index", s, k4, mth) unless s.key?(k4)
820
+ return invalid("'#{id}' index", mth, 3) unless s[k4]
821
+ return mismatch("'#{id}' index", s[k4], cl5, mth) unless s[k4].is_a?(cl5)
822
+ return negative("'#{id}' index", mth) if s[k4] < 0
823
+ return hashkey("'#{id}' Rsi", s, :r, mth) unless s.key?(:r)
824
+ return invalid("'#{id}' Rsi", mth, 3) unless s[:r]
825
+ return mismatch("'#{id}' Rsi", s[:r], cl3, mth) unless s[:r].is_a?(cl3)
826
+ return zero("'#{id}' Rsi", mth, WRN) if s[:r].abs < 0.001
827
+
828
+ derated = lc.nameString.include?(" tbd")
829
+ log(WRN, "Won't derate '#{id}': already derated (#{mth})") if derated
830
+ return m if derated
831
+
832
+ index = s[:index]
833
+ ltype = s[:ltype]
834
+ r = s[:r]
835
+ u = s[:heatloss] / s[:net]
836
+ loss = 0
837
+ de_u = 1 / r + u # derated U
838
+ de_r = 1 / de_u # derated R
839
+
840
+ if ltype == :massless
841
+ m = lc.getLayer(index).to_MasslessOpaqueMaterial
842
+ return invalid("'#{id}' massless layer?", mth, 0) if m.empty?
843
+ m = m.get
844
+ up = ""
845
+ up = "uprated " if m.nameString.include?(" uprated")
846
+ m = m.clone(model).to_MasslessOpaqueMaterial.get
847
+ m.setName("'#{id}' #{up}m tbd")
848
+ de_r = 0.001 unless de_r > 0.001
849
+ loss = (de_u - 1 / de_r) * s[:net] unless de_r > 0.001
850
+ m.setThermalResistance(de_r)
851
+ else
852
+ m = lc.getLayer(index).to_StandardOpaqueMaterial
853
+ return invalid("'#{id}' standard layer?", mth, 0) if m.empty?
854
+ m = m.get
855
+ up = ""
856
+ up = "uprated " if m.nameString.include?(" uprated")
857
+ m = m.clone(model).to_StandardOpaqueMaterial.get
858
+ m.setName("'#{id}' #{up}m tbd")
859
+ k = m.thermalConductivity
860
+
861
+ if de_r > 0.001
862
+ d = de_r * k
863
+
864
+ unless d > 0.003
865
+ d = 0.003
866
+ k = d / de_r
867
+ k = 3 unless k < 3
868
+ loss = (de_u - k / d) * s[:net] unless k < 3
869
+ end
870
+ else # de_r < 0.001 m2.K/W
871
+ d = 0.001 * k
872
+ d = 0.003 unless d > 0.003
873
+ k = d / 0.001 unless d > 0.003
874
+ loss = (de_u - k / d) * s[:net]
875
+ end
876
+
877
+ m.setThickness(d)
878
+ m.setThermalConductivity(k)
879
+ end
880
+
881
+ if m && loss > TOL
882
+ s[:r_heatloss] = loss
883
+ h_loss = format "%.3f", s[:r_heatloss]
884
+ log(WRN, "Won't assign #{h_loss} W/K to '#{id}': too conductive (#{mth})")
885
+ end
886
+
887
+ m
888
+ end
889
+
890
+ ##
891
+ # Process TBD objects, based on OpenStudio model (OSM) and Topolys model,
892
+ # and derate admissible envelope surfaces by substituting insulating material
893
+ # within surface multilayered constructions with derated clones. Returns a
894
+ # hash holding 2x key:value pairs ... io: objects for JSON serialization and
895
+ # surfaces: derated TBD surfaces.
896
+ #
897
+ # @param model [OpenStudio::Model::Model] a model
898
+ # @param argh [Hash] TBD arguments
899
+ #
900
+ # @return [Hash] io: (Hash), surfaces: (Hash)
901
+ # @return [Hash] io: nil, surfaces: nil (if invalid input)
902
+ def process(model = nil, argh = {})
903
+ mth = "TBD::#{__callee__}"
904
+ cl = OpenStudio::Model::Model
905
+ tbd = { io: nil, surfaces: {} }
906
+
907
+ return mismatch("model", model, cl, mth, DBG, tbd) unless model.is_a?(cl)
908
+ return mismatch("argh", argh, Hash, mth, DBG, tbd) unless argh.is_a?(Hash)
909
+
910
+ argh = {} if argh.empty?
911
+ argh[:option ] = "" unless argh.key?(:option )
912
+ argh[:io_path ] = nil unless argh.key?(:io_path )
913
+ argh[:schema_path ] = nil unless argh.key?(:schema_path )
914
+ argh[:uprate_walls ] = false unless argh.key?(:uprate_walls )
915
+ argh[:uprate_roofs ] = false unless argh.key?(:uprate_roofs )
916
+ argh[:uprate_floors] = false unless argh.key?(:uprate_floors)
917
+ argh[:wall_ut ] = 0 unless argh.key?(:wall_ut )
918
+ argh[:roof_ut ] = 0 unless argh.key?(:roof_ut )
919
+ argh[:floor_ut ] = 0 unless argh.key?(:floor_ut )
920
+ argh[:wall_option ] = "" unless argh.key?(:wall_option )
921
+ argh[:roof_option ] = "" unless argh.key?(:roof_option )
922
+ argh[:floor_option ] = "" unless argh.key?(:floor_option )
923
+ argh[:gen_ua ] = false unless argh.key?(:gen_ua )
924
+ argh[:ua_ref ] = "" unless argh.key?(:ua_ref )
925
+ argh[:gen_kiva ] = false unless argh.key?(:gen_kiva )
926
+
927
+ # Create the Topolys Model.
928
+ t_model = Topolys::Model.new
929
+
930
+ # "true" if any space/zone holds valid setpoint temperatures. With invalid
931
+ # inputs, these 2x methods return "false", ignoring any
932
+ # setpoint-based logic, e.g. semi-heated spaces (DEBUG errors are logged).
933
+ setpoints = heatingTemperatureSetpoints?(model)
934
+ setpoints = coolingTemperatureSetpoints?(model) || setpoints
935
+
936
+ # "true" if any space/zone is part of an HVAC air loop. With invalid inputs,
937
+ # the method returns "false", ignoring any air-loop related logic, e.g.
938
+ # plenum zones as HVAC objects (DEBUG errors are logged).
939
+ airloops = airLoopsHVAC?(model)
940
+
941
+ model.getSurfaces.sort_by { |s| s.nameString }.each do |s|
942
+ # Fetch key attributes of opaque surfaces. Method returns nil with invalid
943
+ # input (DEBUG and ERROR messages may be logged). TBD ignores them.
944
+ surface = properties(model, s)
945
+ next if surface.nil?
946
+
947
+ # Similar to "setpoints?" methods above, the boolean methods below also
948
+ # return "false" with invalid inputs, ignoring any space/zone
949
+ # conditioning-based logic (e.g. semi-heated spaces, mislabelling a
950
+ # plenum as an unconditioned zone).
951
+ if setpoints
952
+ if surface[:space].thermalZone.empty?
953
+ plenum = plenum?(surface[:space], airloops, setpoints)
954
+ surface[:conditioned] = false unless plenum
955
+ else
956
+ zone = surface[:space].thermalZone.get
957
+ heat = maxHeatScheduledSetpoint(zone)
958
+ cool = minCoolScheduledSetpoint(zone)
959
+
960
+ unless heat[:spt] || cool[:spt]
961
+ plenum = plenum?(surface[:space], airloops, setpoints)
962
+ heat[:spt] = 21 if plenum
963
+ cool[:spt] = 24 if plenum
964
+ surface[:conditioned] = false unless plenum
965
+ end
966
+
967
+ free = heat[:spt] && heat[:spt] < -40 && cool[:spt] && cool[:spt] > 40
968
+ surface[:conditioned] = false if free
969
+ end
970
+ end
971
+
972
+ surface[:heating] = heat[:spt] if heat[:spt] # if valid heating setpoints
973
+ surface[:cooling] = cool[:spt] if cool[:spt] # if valid cooling setpoints
974
+
975
+ tbd[:surfaces][s.nameString] = surface
976
+ end # (opaque) surfaces populated
977
+
978
+ return empty("TBD surfaces", mth, ERR, tbd) if tbd[:surfaces].empty?
979
+
980
+ # TBD only derates constructions of opaque surfaces in CONDITIONED spaces,
981
+ # ... if facing outdoors or facing UNENCLOSED/UNCONDITIONED spaces.
982
+ tbd[:surfaces].each do |id, surface|
983
+ surface[:deratable] = false
984
+
985
+ next unless surface[:conditioned]
986
+ next if surface[:ground]
987
+
988
+ unless surface[:boundary].downcase == "outdoors"
989
+ next unless tbd[:surfaces].key?(surface[:boundary])
990
+ next if tbd[:surfaces][surface[:boundary]][:conditioned]
991
+ end
992
+
993
+ ok = surface.key?(:index)
994
+ surface[:deratable] = true if ok
995
+ log(ERR, "Skipping '#{id}': insulating layer? (#{mth})") unless ok
996
+ end
997
+
998
+ [:windows, :doors, :skylights].each do |holes| # sort kids
999
+ tbd[:surfaces].values.each do |surface|
1000
+ ok = surface.key?(holes)
1001
+ surface[holes] = surface[holes].sort_by { |_, s| s[:minz] }.to_h if ok
1002
+ end
1003
+ end
1004
+
1005
+ # Split "surfaces" hash into "floors", "ceilings" and "walls" hashes.
1006
+ floors = tbd[:surfaces].select { |_, s| s[:type] == :floor }
1007
+ ceilings = tbd[:surfaces].select { |_, s| s[:type] == :ceiling }
1008
+ walls = tbd[:surfaces].select { |_, s| s[:type] == :wall }
1009
+ floors = floors.sort_by { |_, s| [s[:minz], s[:space]] }.to_h
1010
+ ceilings = ceilings.sort_by { |_, s| [s[:minz], s[:space]] }.to_h
1011
+ walls = walls.sort_by { |_, s| [s[:minz], s[:space]] }.to_h
1012
+
1013
+ # Fetch OpenStudio shading surfaces & key attributes.
1014
+ shades = {}
1015
+
1016
+ model.getShadingSurfaces.each do |s|
1017
+ id = s.nameString
1018
+ empty = s.shadingSurfaceGroup.empty?
1019
+ log(ERR, "Can't process '#{id}' transformation (#{mth})") if empty
1020
+ next if empty
1021
+ group = s.shadingSurfaceGroup.get
1022
+ shading = group.to_ShadingSurfaceGroup
1023
+ tr = transforms(model, group)
1024
+ ok = tr[:t] && tr[:r]
1025
+ t = tr[:t]
1026
+ log(FTL, "Can't process '#{id}' transformation (#{mth})") unless ok
1027
+ return tbd unless ok
1028
+
1029
+ unless shading.empty?
1030
+ empty = shading.get.space.empty?
1031
+ tr[:r] += shading.get.space.get.directionofRelativeNorth unless empty
1032
+ end
1033
+
1034
+ n = trueNormal(s, tr[:r])
1035
+ log(FTL, "Can't process '#{id}' true normal (#{mth})") unless n
1036
+ return tbd unless n
1037
+
1038
+ points = (t * s.vertices).map { |v| Topolys::Point3D.new(v.x, v.y, v.z) }
1039
+ minz = ( points.map { |p| p.z } ).min
1040
+ shades[id] = { group: group, points: points, minz: minz, n: n }
1041
+ end # shading surfaces populated
1042
+
1043
+ # Mutually populate TBD & Topolys surfaces. Keep track of created "holes".
1044
+ holes = {}
1045
+ floor_holes = dads(t_model, floors )
1046
+ ceiling_holes = dads(t_model, ceilings)
1047
+ wall_holes = dads(t_model, walls )
1048
+
1049
+ holes.merge!(floor_holes )
1050
+ holes.merge!(ceiling_holes)
1051
+ holes.merge!(wall_holes )
1052
+ dads(t_model, shades)
1053
+
1054
+ # Loop through Topolys edges and populate TBD edge hash. Initially, there
1055
+ # should be a one-to-one correspondence between Topolys and TBD edge
1056
+ # objects. Use Topolys-generated identifiers as unique edge hash keys.
1057
+ edges = {}
1058
+
1059
+ holes.each do |id, wire| # start with hole edges
1060
+ wire.edges.each do |e|
1061
+ i = e.id
1062
+ l = e.length
1063
+ ok = edges.key?(i)
1064
+ edges[i] = { length: l, v0: e.v0, v1: e.v1, surfaces: {} } unless ok
1065
+ ok = edges[i][:surfaces].key?(wire.attributes[:id])
1066
+ edges[i][:surfaces][wire.attributes[:id]] = { wire: wire.id } unless ok
1067
+ end
1068
+ end
1069
+
1070
+ # Next, floors, ceilings & walls; then shades.
1071
+ faces(floors, edges )
1072
+ faces(ceilings, edges)
1073
+ faces(walls, edges )
1074
+ faces(shades, edges )
1075
+
1076
+ # Generate OSM Kiva settings and objects if foundation-facing floors.
1077
+ # returns false if partial failure (log failure eventually).
1078
+ kiva(model, walls, floors, edges) if argh[:gen_kiva]
1079
+
1080
+ # Thermal bridging characteristics of edges are determined - in part - by
1081
+ # relative polar position of linked surfaces (or wires) around each edge.
1082
+ # This attribute is key in distinguishing concave from convex edges.
1083
+ #
1084
+ # For each linked surface (or rather surface wires), set polar position
1085
+ # around edge with respect to a reference vector (perpendicular to the
1086
+ # edge), +clockwise as one is looking in the opposite position of the edge
1087
+ # vector. For instance, a vertical edge has a reference vector pointing
1088
+ # North - surfaces eastward of the edge are (0°,180°], while surfaces
1089
+ # westward of the edge are (180°,360°].
1090
+ #
1091
+ # Much of the following code is of a topological nature, and should ideally
1092
+ # (or eventually) become available functionality offered by Topolys. Topolys
1093
+ # "wrappers" like TBD are good, short-term test beds to identify desired
1094
+ # features for future Topolys enhancements.
1095
+ zenith = Topolys::Vector3D.new(0, 0, 1).freeze
1096
+ north = Topolys::Vector3D.new(0, 1, 0).freeze
1097
+ east = Topolys::Vector3D.new(1, 0, 0).freeze
1098
+
1099
+ edges.values.each do |edge|
1100
+ origin = edge[:v0].point
1101
+ terminal = edge[:v1].point
1102
+ dx = (origin.x - terminal.x).abs
1103
+ dy = (origin.y - terminal.y).abs
1104
+ dz = (origin.z - terminal.z).abs
1105
+ horizontal = dz.abs < TOL
1106
+ vertical = dx < TOL && dy < TOL
1107
+ edge_V = terminal - origin
1108
+ edge_plane = Topolys::Plane3D.new(origin, edge_V)
1109
+
1110
+ if vertical
1111
+ reference_V = north.dup
1112
+ elsif horizontal
1113
+ reference_V = zenith.dup
1114
+ else # project zenith vector unto edge plane
1115
+ reference = edge_plane.project(origin + zenith)
1116
+ reference_V = reference - origin
1117
+ end
1118
+
1119
+ edge[:surfaces].each do |id, surface|
1120
+ # Loop through each linked wire and determine farthest point from
1121
+ # edge while ensuring candidate point is not aligned with edge.
1122
+ t_model.wires.each do |wire|
1123
+ if surface[:wire] == wire.id # there should be a unique match
1124
+ normal = tbd[:surfaces][id][:n] if tbd[:surfaces].key?(id)
1125
+ normal = holes[id].attributes[:n] if holes.key?(id)
1126
+ normal = shades[id][:n] if shades.key?(id)
1127
+ farthest = Topolys::Point3D.new(origin.x, origin.y, origin.z)
1128
+ farthest_V = farthest - origin # zero magnitude, initially
1129
+ inverted = false
1130
+ i_origin = wire.points.index(origin)
1131
+ i_terminal = wire.points.index(terminal)
1132
+ i_last = wire.points.size - 1
1133
+
1134
+ if i_terminal == 0
1135
+ inverted = true unless i_origin == i_last
1136
+ elsif i_origin == i_last
1137
+ inverted = true unless i_terminal == 0
1138
+ else
1139
+ inverted = true unless i_terminal - i_origin == 1
1140
+ end
1141
+
1142
+ wire.points.each do |point|
1143
+ next if point == origin
1144
+ next if point == terminal
1145
+ point_on_plane = edge_plane.project(point)
1146
+ origin_point_V = point_on_plane - origin
1147
+ point_V_magnitude = origin_point_V.magnitude
1148
+ next unless point_V_magnitude > TOL
1149
+
1150
+ # Generate a plane between origin, terminal & point. Only consider
1151
+ # planes that share the same normal as wire.
1152
+ if inverted
1153
+ plane = Topolys::Plane3D.from_points(terminal, origin, point)
1154
+ else
1155
+ plane = Topolys::Plane3D.from_points(origin, terminal, point)
1156
+ end
1157
+
1158
+ next unless (normal.x - plane.normal.x).abs < TOL &&
1159
+ (normal.y - plane.normal.y).abs < TOL &&
1160
+ (normal.z - plane.normal.z).abs < TOL
1161
+
1162
+ farther = point_V_magnitude > farthest_V.magnitude
1163
+ farthest = point if farther
1164
+ farthest_V = origin_point_V if farther
1165
+ end
1166
+
1167
+ angle = reference_V.angle(farthest_V)
1168
+ adjust = false # adjust angle [180°, 360°] if necessary
1169
+
1170
+ if vertical
1171
+ adjust = true if east.dot(farthest_V) < -TOL
1172
+ else
1173
+ if north.dot(farthest_V).abs < TOL ||
1174
+ (north.dot(farthest_V).abs - 1).abs < TOL
1175
+ adjust = true if east.dot(farthest_V) < -TOL
1176
+ else
1177
+ adjust = true if north.dot(farthest_V) < -TOL
1178
+ end
1179
+ end
1180
+
1181
+ angle = 2 * Math::PI - angle if adjust
1182
+ angle -= 2 * Math::PI if (angle - 2 * Math::PI).abs < TOL
1183
+ surface[:angle] = angle
1184
+ farthest_V.normalize!
1185
+ surface[:polar] = farthest_V
1186
+ surface[:normal] = normal
1187
+ end
1188
+ end # end of edge-linked, surface-to-wire loop
1189
+ end # end of edge-linked surface loop
1190
+
1191
+ edge[:horizontal] = horizontal
1192
+ edge[:vertical ] = vertical
1193
+ edge[:surfaces ] = edge[:surfaces].sort_by{ |i, p| p[:angle] }.to_h
1194
+ end # end of edge loop
1195
+
1196
+ # Topolys edges may constitute thermal bridges (and therefore thermally
1197
+ # derate linked OpenStudio opaque surfaces), depending on a number of
1198
+ # factors such as surface type, space conditioning and boundary conditions.
1199
+ # Thermal bridging attributes (type & PSI-value pairs) are grouped into PSI
1200
+ # sets, normally accessed through the :option user argument (in the
1201
+ # OpenStudio Measure interface).
1202
+ #
1203
+ # Process user-defined TBD JSON file inputs if file exists & valid:
1204
+ # :io holds valid TBD JSON file entries
1205
+ # :psi holds TBD PSI sets (built-in defaults + those on file)
1206
+ # :khi holds TBD KHI points (built-in defaults + those on file)
1207
+ #
1208
+ # Without an input JSON file, a valid 'json' Hash simply holds:
1209
+ # :io[:building][:psi] ... a single valid, default PSI set for all edges
1210
+ # :psi ... built-in TBD PSI sets
1211
+ # :khi ... built-in TBD KHI points
1212
+ json = inputs(tbd[:surfaces], edges, argh)
1213
+
1214
+ # A user-defined TBD JSON input file can hold a number of anomalies that
1215
+ # won't affect results, such as custom PSI sets that aren't referenced
1216
+ # elsewhere (similar to OpenStudio materials on file that aren't referenced
1217
+ # by any OpenStudio construction). This may trigger 'warnings' in the log
1218
+ # file, but they're in principle benign.
1219
+ #
1220
+ # A user-defined JSON input file can instead hold a number of more serious
1221
+ # anomalies that risk generating erroneous or unintended results. They're
1222
+ # logged as well, yet it remains up to the user to decide how serious a risk
1223
+ # this may be. If a custom edge is defined on file (e.g., "expansion joint"
1224
+ # thermal bridge instead of a "transition") yet TBD is unable to match
1225
+ # it against OpenStudio and/or Topolys edges (or surfaces), then TBD
1226
+ # will log this as an error while simply 'skipping' the anomaly (TBD will
1227
+ # otherwise ignore the requested change and pursue its processes).
1228
+ #
1229
+ # There are 2 types of errors that are considered FATAL when processing
1230
+ # user-defined TBD JSON input files:
1231
+ # - incorrect JSON formatting of the input file (can't parse)
1232
+ # - TBD is unable to identify a 'complete' building-level PSI set
1233
+ # (either a bad argument from the Measure, or bad input on file).
1234
+ #
1235
+ # ... in such circumstances, TBD will halt all processes and exit while
1236
+ # signaling to OpenStudio to halt its own processes (e.g., not launch an
1237
+ # EnergyPlus simulation). This is similar to accessing an invalid .osm file.
1238
+ return tbd if fatal?
1239
+
1240
+ psi = json[:io][:building][:psi] # default building PSI on file
1241
+ shorts = json[:psi].shorthands(psi)
1242
+ empty = shorts[:has].empty? || shorts[:val].empty?
1243
+ log(FTL, "Invalid or incomplete building PSI set (#{mth})") if empty
1244
+ return tbd if empty
1245
+
1246
+ edges.values.each do |edge|
1247
+ next unless edge.key?(:surfaces)
1248
+ deratables = []
1249
+
1250
+ edge[:surfaces].keys.each do |id|
1251
+ next unless tbd[:surfaces].key?(id)
1252
+ deratables << id if tbd[:surfaces][id][:deratable]
1253
+ end
1254
+
1255
+ next if deratables.empty?
1256
+ set = {}
1257
+
1258
+ if edge.key?(:io_type)
1259
+ bdg = json[:psi].safe(psi, edge[:io_type]) # building safe type fallback
1260
+ edge[:sets] = {} unless edge.key?(:sets)
1261
+ edge[:sets][edge[:io_type]] = shorts[:val][bdg] # building safe fallback
1262
+ set[edge[:io_type]] = shorts[:val][bdg]
1263
+ edge[:psi] = set
1264
+
1265
+ if edge.key?(:io_set) && json[:psi].set.key?(edge[:io_set])
1266
+ type = json[:psi].safe(edge[:io_set], edge[:io_type])
1267
+ edge[:set] = edge[:io_set] if type
1268
+ end
1269
+
1270
+ match = true
1271
+ end
1272
+
1273
+ edge[:surfaces].keys.each do |id|
1274
+ break if match
1275
+ next unless tbd[:surfaces].key?(id)
1276
+ next unless deratables.include?(id)
1277
+
1278
+ # Evaluate current set content before processing a new linked surface.
1279
+ is = {}
1280
+ is[:head ] = set.keys.to_s.include?("head" )
1281
+ is[:sill ] = set.keys.to_s.include?("sill" )
1282
+ is[:jamb ] = set.keys.to_s.include?("jamb" )
1283
+ is[:corner ] = set.keys.to_s.include?("corner" )
1284
+ is[:parapet ] = set.keys.to_s.include?("parapet" )
1285
+ is[:party ] = set.keys.to_s.include?("party" )
1286
+ is[:grade ] = set.keys.to_s.include?("grade" )
1287
+ is[:balcony ] = set.keys.to_s.include?("balcony" )
1288
+ is[:rimjoist] = set.keys.to_s.include?("rimjoist")
1289
+
1290
+ # Label edge as :head, :sill or :jamb if linked to:
1291
+ # 1x subsurface
1292
+ edge[:surfaces].keys.each do |i|
1293
+ break if is[:head] || is[:sill] || is[:jamb]
1294
+ next if deratables.include?(i)
1295
+ next unless holes.key?(i)
1296
+
1297
+ gardian = ""
1298
+ gardian = id if deratables.size == 1 # just dad
1299
+
1300
+ if gardian.empty? # seek uncle
1301
+ pops = {} # kids?
1302
+ uncles = {} # nieces?
1303
+ boys = [] # kids
1304
+ nieces = [] # nieces
1305
+ uncle = deratables.first unless deratables.first == id # uncle 1st?
1306
+ uncle = deratables.last unless deratables.last == id # uncle 2nd?
1307
+
1308
+ pops[:w ] = tbd[:surfaces][id ].key?(:windows )
1309
+ pops[:d ] = tbd[:surfaces][id ].key?(:doors )
1310
+ pops[:s ] = tbd[:surfaces][id ].key?(:skylights)
1311
+ uncles[:w] = tbd[:surfaces][uncle].key?(:windows )
1312
+ uncles[:d] = tbd[:surfaces][uncle].key?(:doors )
1313
+ uncles[:s] = tbd[:surfaces][uncle].key?(:skylights)
1314
+
1315
+ boys += tbd[:surfaces][id ][:windows ].keys if pops[:w]
1316
+ boys += tbd[:surfaces][id ][:doors ].keys if pops[:d]
1317
+ boys += tbd[:surfaces][id ][:skylights].keys if pops[:s]
1318
+ nieces += tbd[:surfaces][uncle][:windows ].keys if uncles[:w]
1319
+ nieces += tbd[:surfaces][uncle][:doors ].keys if uncles[:d]
1320
+ nieces += tbd[:surfaces][uncle][:skylights].keys if uncles[:s]
1321
+
1322
+ gardian = uncle if boys.include?(i)
1323
+ gardian = id if nieces.include?(i)
1324
+ end
1325
+
1326
+ next if gardian.empty?
1327
+ s1 = edge[:surfaces][gardian]
1328
+ s2 = edge[:surfaces][i]
1329
+ concave = concave?(s1, s2)
1330
+ convex = convex?(s1, s2)
1331
+ flat = !concave && !convex
1332
+
1333
+ # Subsurface edges are tagged as :head, :sill or :jamb, regardless
1334
+ # of building PSI set subsurface tags. If the latter is simply
1335
+ # :fenestration, then its (single) PSI value is systematically
1336
+ # attributed to subsurface :head, :sill & :jamb edges. If absent,
1337
+ # concave or convex variants also inherit from base type.
1338
+ #
1339
+ # TBD tags a subsurface edge as :jamb if the subsurface is "flat".
1340
+ # If not flat, TBD tags a horizontal edge as either :head or :sill
1341
+ # based on the polar angle of the subsurface around the edge vs sky
1342
+ # zenith. Otherwise, all other subsurface edges are tagged as :jamb.
1343
+ if ((s2[:normal].dot(zenith)).abs - 1).abs < TOL
1344
+ set[:jamb ] = shorts[:val][:jamb ] if flat
1345
+ set[:jambconcave] = shorts[:val][:jambconcave] if concave
1346
+ set[:jambconvex ] = shorts[:val][:jambconvex ] if convex
1347
+ is[:jamb ] = true
1348
+ else
1349
+ if edge[:horizontal]
1350
+ if s2[:polar].dot(zenith) < 0
1351
+ set[:head ] = shorts[:val][:head ] if flat
1352
+ set[:headconcave] = shorts[:val][:headconcave] if concave
1353
+ set[:headconvex ] = shorts[:val][:headconvex ] if convex
1354
+ is[:head ] = true
1355
+ else
1356
+ set[:sill ] = shorts[:val][:sill ] if flat
1357
+ set[:sillconcave] = shorts[:val][:sillconcave] if concave
1358
+ set[:sillconvex ] = shorts[:val][:sillconvex ] if convex
1359
+ is[:sill ] = true
1360
+ end
1361
+ else
1362
+ set[:jamb ] = shorts[:val][:jamb ] if flat
1363
+ set[:jambconcave] = shorts[:val][:jambconcave] if concave
1364
+ set[:jambconvex ] = shorts[:val][:jambconvex ] if convex
1365
+ is[:jamb ] = true
1366
+ end
1367
+ end
1368
+ end
1369
+
1370
+ # Label edge as :cornerconcave or :cornerconvex if linked to:
1371
+ # 2x deratable walls & f(relative polar wall vectors around edge)
1372
+ edge[:surfaces].keys.each do |i|
1373
+ break if is[:corner]
1374
+ break unless deratables.size == 2
1375
+ break unless walls.key?(id)
1376
+ next if i == id
1377
+ next unless deratables.include?(i)
1378
+ next unless walls.key?(i)
1379
+
1380
+ s1 = edge[:surfaces][id]
1381
+ s2 = edge[:surfaces][i]
1382
+ concave = concave?(s1, s2)
1383
+ convex = convex?(s1, s2)
1384
+
1385
+ set[:cornerconcave] = shorts[:val][:cornerconcave] if concave
1386
+ set[:cornerconvex ] = shorts[:val][:cornerconvex ] if convex
1387
+ is[:corner ] = true
1388
+ end
1389
+
1390
+ # Label edge as :parapet if linked to:
1391
+ # 1x deratable wall
1392
+ # 1x deratable ceiling
1393
+ edge[:surfaces].keys.each do |i|
1394
+ break if is[:parapet]
1395
+ break unless deratables.size == 2
1396
+ break unless ceilings.key?(id)
1397
+ next if i == id
1398
+ next unless deratables.include?(i)
1399
+ next unless walls.key?(i)
1400
+
1401
+ s1 = edge[:surfaces][id]
1402
+ s2 = edge[:surfaces][i]
1403
+ concave = concave?(s1, s2)
1404
+ convex = convex?(s1, s2)
1405
+ flat = !concave && !convex
1406
+
1407
+ set[:parapet ] = shorts[:val][:parapet ] if flat
1408
+ set[:parapetconcave] = shorts[:val][:parapetconcave] if concave
1409
+ set[:parapetconvex ] = shorts[:val][:parapetconvex ] if convex
1410
+ is[:parapet ] = true
1411
+ end
1412
+
1413
+ # Label edge as :party if linked to:
1414
+ # 1x adiabatic surface
1415
+ # 1x (only) deratable surface
1416
+ edge[:surfaces].keys.each do |i|
1417
+ break if is[:party]
1418
+ break unless deratables.size == 1
1419
+ next if i == id
1420
+ next unless tbd[:surfaces].key?(i)
1421
+ next if holes.key?(i)
1422
+ next if shades.key?(i)
1423
+ next unless tbd[:surfaces][i][:boundary].downcase == "adiabatic"
1424
+
1425
+ s1 = edge[:surfaces][id]
1426
+ s2 = edge[:surfaces][i]
1427
+ concave = concave?(s1, s2)
1428
+ convex = convex?(s1, s2)
1429
+ flat = !concave && !convex
1430
+
1431
+ set[:party ] = shorts[:val][:party ] if flat
1432
+ set[:partyconcave] = shorts[:val][:partyconcave] if concave
1433
+ set[:partyconvex ] = shorts[:val][:partyconvex ] if convex
1434
+ is[:party ] = true
1435
+ end
1436
+
1437
+ # Label edge as :grade if linked to:
1438
+ # 1x surface (e.g. slab or wall) facing ground
1439
+ # 1x surface (i.e. wall) facing outdoors
1440
+ edge[:surfaces].keys.each do |i|
1441
+ break if is[:grade]
1442
+ break unless deratables.size == 1
1443
+ next if i == id
1444
+ next unless tbd[:surfaces].key?(i)
1445
+ next unless tbd[:surfaces][i].key?(:ground)
1446
+ next unless tbd[:surfaces][i][:ground]
1447
+
1448
+ s1 = edge[:surfaces][id]
1449
+ s2 = edge[:surfaces][i]
1450
+ concave = concave?(s1, s2)
1451
+ convex = convex?(s1, s2)
1452
+ flat = !concave && !convex
1453
+
1454
+ set[:grade ] = shorts[:val][:grade ] if flat
1455
+ set[:gradeconcave] = shorts[:val][:gradeconcave] if concave
1456
+ set[:gradeconvex ] = shorts[:val][:gradeconvex ] if convex
1457
+ is[:grade ] = true
1458
+ end
1459
+
1460
+ # Label edge as :rimjoist (or :balcony) if linked to:
1461
+ # 1x deratable surface
1462
+ # 1x CONDITIONED floor
1463
+ # 1x shade (optional)
1464
+ balcony = false
1465
+
1466
+ edge[:surfaces].keys.each do |i|
1467
+ break if balcony
1468
+ next if i == id
1469
+ balcony = true if shades.key?(i)
1470
+ end
1471
+
1472
+ edge[:surfaces].keys.each do |i|
1473
+ break if is[:rimjoist] || is[:balcony]
1474
+ break unless deratables.size == 2
1475
+ break if floors.key?(id)
1476
+ next if i == id
1477
+ next unless floors.key?(i)
1478
+ next unless floors[i].key?(:conditioned)
1479
+ next unless floors[i][:conditioned]
1480
+ next if floors[i][:ground]
1481
+
1482
+ other = deratables.first unless deratables.first == id
1483
+ other = deratables.last unless deratables.last == id
1484
+
1485
+ s1 = edge[:surfaces][id]
1486
+ s2 = edge[:surfaces][other]
1487
+ concave = concave?(s1, s2)
1488
+ convex = convex?(s1, s2)
1489
+ flat = !concave && !convex
1490
+
1491
+ if balcony
1492
+ set[:balcony ] = shorts[:val][:balcony ] if flat
1493
+ set[:balconyconcave ] = shorts[:val][:balconyconcave ] if concave
1494
+ set[:balconyconvex ] = shorts[:val][:balconyconvex ] if convex
1495
+ is[:balcony ] = true
1496
+ else
1497
+ set[:rimjoist ] = shorts[:val][:rimjoist ] if flat
1498
+ set[:rimjoistconcave] = shorts[:val][:rimjoistconcave] if concave
1499
+ set[:rimjoistconvex ] = shorts[:val][:rimjoistconvex ] if convex
1500
+ is[:rimjoist ] = true
1501
+ end
1502
+ end
1503
+ end # edge's surfaces loop
1504
+
1505
+ edge[:psi] = set unless set.empty?
1506
+ edge[:set] = psi unless set.empty?
1507
+ end # edge loop
1508
+
1509
+ # Tracking (mild) transitions between deratable surfaces around edges that
1510
+ # have not been previously tagged.
1511
+ edges.values.each do |edge|
1512
+ next if edge.key?(:psi)
1513
+ next unless edge.key?(:surfaces)
1514
+ deratable = false
1515
+
1516
+ edge[:surfaces].keys.each do |id|
1517
+ next unless tbd[:surfaces].key?(id)
1518
+ next unless tbd[:surfaces][id][:deratable]
1519
+ deratable = tbd[:surfaces][id][:deratable]
1520
+ end
1521
+
1522
+ next unless deratable
1523
+ edge[:psi] = { transition: 0.000 }
1524
+ edge[:set] = json[:io][:building][:psi]
1525
+ end
1526
+
1527
+ # 'Unhinged' subsurfaces, like Tubular Daylight Device (TDD) domes,
1528
+ # usually don't share edges with parent surfaces, e.g. floating 300mm above
1529
+ # parent roof surface. Add parent surface ID to unhinged edges.
1530
+ edges.values.each do |edge|
1531
+ next if edge.key?(:psi)
1532
+ next unless edge.key?(:surfaces)
1533
+ next unless edge[:surfaces].size == 1
1534
+ id = edge[:surfaces].first.first
1535
+ next unless holes.key?(id)
1536
+ next unless holes[id].attributes.key?(:unhinged)
1537
+ next unless holes[id].attributes[:unhinged]
1538
+
1539
+ subsurface = model.getSubSurfaceByName(id)
1540
+ next if subsurface.empty?
1541
+ subsurface = subsurface.get
1542
+ surface = subsurface.surface
1543
+ next if surface.empty?
1544
+ nom = surface.get.nameString
1545
+ next unless tbd[:surfaces].key?(nom)
1546
+ next unless tbd[:surfaces][nom].key?(:conditioned)
1547
+ next unless tbd[:surfaces][nom][:conditioned]
1548
+
1549
+ edge[:surfaces][nom] = {}
1550
+
1551
+ set = {}
1552
+ set[:jamb] = shorts[:val][:jamb]
1553
+ edge[:psi] = set
1554
+ edge[:set] = json[:io][:building][:psi]
1555
+ end
1556
+
1557
+ # A priori, TBD applies (default) :building PSI types and values to
1558
+ # individual edges. If a TBD JSON input file holds custom PSI sets for:
1559
+ # :stories
1560
+ # :spacetypes
1561
+ # :surfaces
1562
+ # :edges
1563
+ # ... that may apply to individual edges, then the default :building PSI
1564
+ # types and/or values are overridden, as follows:
1565
+ # custom :stories PSI sets trump :building PSI sets
1566
+ # custom :spacetypes PSI sets trump aforementioned PSI sets
1567
+ # custom :spaces PSI sets trump aforementioned PSI sets
1568
+ # custom :surfaces PSI sets trump aforementioned PSI sets
1569
+ # custom :edges PSI sets trump aforementioned PSI sets
1570
+ if json[:io]
1571
+ if json[:io].key?(:subsurfaces) # reset subsurface U-factors (if on file)
1572
+ json[:io][:subsurfaces].each do |sub|
1573
+ next unless sub.key?(:id)
1574
+ next unless sub.key?(:usi)
1575
+ match = false
1576
+
1577
+ tbd[:surfaces].values.each do |surface|
1578
+ break if match
1579
+
1580
+ [:windows, :doors, :skylights].each do |types|
1581
+ if surface.key?(types)
1582
+ surface[types].each do |id, opening|
1583
+ break if match
1584
+ next unless opening.key?(:u)
1585
+ match = true if sub[:id] == id
1586
+ opening[:u] = sub[:usi] if sub[:id] == id
1587
+ end
1588
+ end
1589
+ end
1590
+ end
1591
+ end
1592
+ end
1593
+
1594
+ [:stories, :spacetypes, :spaces].each do |groups|
1595
+ key = :story
1596
+ key = :stype if groups == :spacetypes
1597
+ key = :space if groups == :spaces
1598
+ next unless json[:io].key?(groups)
1599
+
1600
+ json[:io][groups].each do |group|
1601
+ next unless group.key?(:id)
1602
+ next unless group.key?(:psi)
1603
+ next unless json[:psi].set.key?(group[:psi])
1604
+ sh = json[:psi].shorthands(group[:psi])
1605
+ next if sh[:val].empty?
1606
+
1607
+ edges.values.each do |edge|
1608
+ next if edge.key?(:io_set)
1609
+ next unless edge.key?(:psi)
1610
+ next unless edge.key?(:surfaces)
1611
+
1612
+ edge[:surfaces].keys.each do |id|
1613
+ next unless tbd[:surfaces].key?(id)
1614
+ next unless tbd[:surfaces][id].key?(key)
1615
+ next unless group[:id] == tbd[:surfaces][id][key].nameString
1616
+
1617
+ edge[groups] = {} unless edge.key?(groups)
1618
+ edge[groups][group[:psi]] = {}
1619
+ set = {}
1620
+
1621
+ if edge.key?(:io_type)
1622
+ safer = json[:psi].safe(group[:psi], edge[:io_type])
1623
+ set[edge[:io_type]] = sh[:val][safer] if safer
1624
+ else
1625
+ edge[:psi].keys.each do |type|
1626
+ safer = json[:psi].safe(group[:psi], type)
1627
+ set[type] = sh[:val][safer] if safer
1628
+ end
1629
+ end
1630
+
1631
+ edge[groups][group[:psi]] = set unless set.empty?
1632
+ end
1633
+ end
1634
+ end
1635
+
1636
+ # TBD/Topolys edges will generally be linked to more than one surface
1637
+ # and hence to more than one story. It is possible for a TBD JSON file
1638
+ # to hold 2x story PSI sets that end up targetting one or more edges
1639
+ # common to both stories. In such cases, TBD retains the most conductive
1640
+ # PSI type/value from either story PSI set.
1641
+ edges.values.each do |edge|
1642
+ next unless edge.key?(:psi)
1643
+ next unless edge.key?(groups)
1644
+
1645
+ edge[:psi].keys.each do |type|
1646
+ vals = {}
1647
+
1648
+ edge[groups].keys.each do |set|
1649
+ sh = json[:psi].shorthands(set)
1650
+ next if sh[:val].empty?
1651
+ safer = json[:psi].safe(set, type)
1652
+ vals[set] = sh[:val][safer] if safer
1653
+ end
1654
+
1655
+ next if vals.empty?
1656
+ edge[:psi ][type] = vals.values.max
1657
+ edge[:sets] = {} unless edge.key?(:sets)
1658
+ edge[:sets][type] = vals.key(vals.values.max)
1659
+ end
1660
+ end
1661
+ end
1662
+
1663
+ if json[:io].key?(:surfaces)
1664
+ json[:io][:surfaces].each do |surface|
1665
+ next unless surface.key?(:id)
1666
+ next unless surface.key?(:psi)
1667
+ next unless json[:psi].set.key?(surface[:psi])
1668
+ sh = json[:psi].shorthands(surface[:psi])
1669
+ next if sh[:val].empty?
1670
+
1671
+ edges.values.each do |edge|
1672
+ next if edge.key?(:io_set)
1673
+ next unless edge.key?(:psi)
1674
+ next unless edge.key?(:surfaces)
1675
+
1676
+ edge[:surfaces].each do |id, s|
1677
+ next unless tbd[:surfaces].key?(id)
1678
+ next unless surface[:id] == id
1679
+ set = {}
1680
+
1681
+ if edge.key?(:io_type)
1682
+ safer = json[:psi].safe(surface[:psi], edge[:io_type])
1683
+ set[:io_type] = sh[:val][safer] if safer
1684
+ else
1685
+ edge[:psi].keys.each do |type|
1686
+ safer = json[:psi].safe(surface[:psi], type)
1687
+ set[type] = sh[:val][safer] if safer
1688
+ end
1689
+ end
1690
+
1691
+ s[:psi] = set unless set.empty?
1692
+ s[:set] = surface[:psi] unless set.empty?
1693
+ end
1694
+ end
1695
+ end
1696
+
1697
+ # TBD/Topolys edges will generally be linked to more than one surface. A
1698
+ # TBD JSON file may hold 2x surface PSI sets that target a shared edge.
1699
+ # TBD retains the most conductive PSI type/value from either set.
1700
+ edges.values.each do |edge|
1701
+ next unless edge.key?(:psi)
1702
+ next unless edge.key?(:surfaces)
1703
+
1704
+ edge[:psi].keys.each do |type|
1705
+ vals = {}
1706
+
1707
+ edge[:surfaces].each do |id, s|
1708
+ next unless s.key?(:psi)
1709
+ next unless s.key?(:set)
1710
+ next if s[:set].empty?
1711
+ sh = json[:psi].shorthands(s[:set])
1712
+ next if sh[:val].empty?
1713
+ safer = json[:psi].safe(s[:set], type)
1714
+ vals[s[:set]] = sh[:val][safer] if safer
1715
+ end
1716
+
1717
+ next if vals.empty?
1718
+ edge[:psi][type] = vals.values.max
1719
+ edge[:sets] = {} unless edge.key?(:sets)
1720
+ edge[:sets][type] = vals.key(vals.values.max)
1721
+ end
1722
+ end
1723
+ end
1724
+
1725
+ # Loop through all customized edges on file w/w/o a custom PSI set.
1726
+ edges.values.each do |edge|
1727
+ next unless edge.key?(:psi)
1728
+ next unless edge.key?(:io_type)
1729
+ next unless edge.key?(:surfaces)
1730
+
1731
+ if edge.key?(:io_set)
1732
+ next unless json[:psi].set.key?(edge[:io_set])
1733
+ set = edge[:io_set]
1734
+ else
1735
+ next unless edge[:sets].key?(edge[:io_type])
1736
+ next unless json[:psi].set.key?(edge[:sets][edge[:io_type]])
1737
+ set = edge[:sets][edge[:io_type]]
1738
+ end
1739
+
1740
+ sh = json[:psi].shorthands(set)
1741
+ next if sh[:val].empty?
1742
+ safer = json[:psi].safe(set, edge[:io_type])
1743
+ next unless safer
1744
+
1745
+ if edge.key?(:io_set)
1746
+ edge[:psi] = {}
1747
+ edge[:set] = edge[:io_set]
1748
+ else
1749
+ edge[:sets] = {} unless edge.key?(:sets)
1750
+ edge[:sets][edge[:io_type]] = sh[:val][safer]
1751
+ end
1752
+
1753
+ edge[:psi][edge[:io_type]] = sh[:val][safer]
1754
+ end
1755
+ end
1756
+
1757
+ # Loop through each edge and assign heat loss to linked surfaces.
1758
+ edges.each do |identifier, edge|
1759
+ next unless edge.key?(:psi)
1760
+ rsi = 0
1761
+ max = edge[:psi].values.max
1762
+ type = edge[:psi].key(max)
1763
+ length = edge[:length]
1764
+ bridge = { psi: max, type: type, length: length }
1765
+ deratables = {}
1766
+ apertures = {}
1767
+
1768
+ if edge.key?(:sets) && edge[:sets].key?(type)
1769
+ edge[:set] = edge[:sets][type] unless edge.key?(:io_set)
1770
+ end
1771
+
1772
+ # Retrieve valid linked surfaces as deratables.
1773
+ edge[:surfaces].each do |id, s|
1774
+ next unless tbd[:surfaces].key?(id)
1775
+ next unless tbd[:surfaces][id][:deratable]
1776
+ deratables[id] = s
1777
+ end
1778
+
1779
+ edge[:surfaces].each { |id, s| apertures[id] = s if holes.key?(id) }
1780
+ next if apertures.size > 1 # edge links 2x openings
1781
+
1782
+ # Prune dad if edge links an opening, its dad and an uncle.
1783
+ if deratables.size > 1 && apertures.size > 0
1784
+ deratables.each do |id, deratable|
1785
+ [:windows, :doors, :skylights].each do |types|
1786
+ next unless tbd[:surfaces][id].key?(types)
1787
+
1788
+ tbd[:surfaces][id][types].keys.each do |sub|
1789
+ deratables.delete(id) if apertures.key?(sub)
1790
+ end
1791
+ end
1792
+ end
1793
+ end
1794
+
1795
+ next if deratables.empty?
1796
+
1797
+ # Sum RSI of targeted insulating layer from each deratable surface.
1798
+ deratables.each do |id, deratable|
1799
+ next unless tbd[:surfaces][id].key?(:r)
1800
+ rsi += tbd[:surfaces][id][:r]
1801
+ end
1802
+
1803
+ # Assign heat loss from thermal bridges to surfaces, in proportion to
1804
+ # insulating layer thermal resistance.
1805
+ deratables.each do |id, deratable|
1806
+ ratio = 0
1807
+ ratio = tbd[:surfaces][id][:r] / rsi if rsi > 0.001
1808
+ loss = bridge[:psi] * ratio
1809
+ b = { psi: loss, type: bridge[:type], length: length, ratio: ratio }
1810
+ tbd[:surfaces][id][:edges] = {} unless tbd[:surfaces][id].key?(:edges)
1811
+ tbd[:surfaces][id][:edges][identifier] = b
1812
+ end
1813
+ end
1814
+
1815
+ # Assign thermal bridging heat loss [in W/K] to each deratable surface.
1816
+ tbd[:surfaces].each do |id, surface|
1817
+ next unless surface.key?(:edges)
1818
+ surface[:heatloss] = 0
1819
+ e = surface[:edges].values
1820
+ e.each { |edge| surface[:heatloss] += edge[:psi] * edge[:length] }
1821
+ end
1822
+
1823
+ # Add point conductances (W/K x count), in TBD JSON file (under surfaces).
1824
+ tbd[:surfaces].each do |id, s|
1825
+ next unless s[:deratable]
1826
+ next unless json[:io]
1827
+ next unless json[:io].key?(:surfaces)
1828
+
1829
+ json[:io][:surfaces].each do |surface|
1830
+ next unless surface.key?(:khis)
1831
+ next unless surface.key?(:id)
1832
+ next unless surface[:id] == id
1833
+
1834
+ surface[:khis].each do |k|
1835
+ next unless k.key?(:id)
1836
+ next unless k.key?(:count)
1837
+ next unless json[:khi].point.key?(k[:id])
1838
+ next unless json[:khi].point[k[:id]] > 0.001
1839
+ s[:heatloss] = 0 unless s.key?(:heatloss)
1840
+ s[:heatloss] += json[:khi].point[k[:id]] * k[:count]
1841
+ s[:pts] = {} unless s.key?(:pts)
1842
+ s[:pts][k[:id]] = { val: json[:khi].point[k[:id]], n: k[:count] }
1843
+ end
1844
+ end
1845
+ end
1846
+
1847
+ # If user has selected a Ut to meet, e.g. argh'ments:
1848
+ # :uprate_walls
1849
+ # :wall_ut
1850
+ # :wall_option
1851
+ # (same triple arguments for roofs and exposed floors)
1852
+ # ... first 'uprate' targeted insulation layers (see ua.rb) before derating.
1853
+ # Check for new argh keys [:wall_uo], [:roof_uo] and/or [:floor_uo].
1854
+ up = argh[:uprate_walls] || argh[:uprate_roofs] || argh[:uprate_floors]
1855
+ uprate(model, tbd[:surfaces], argh) if up
1856
+
1857
+ # Derated (cloned) constructions are unique to each deratable surface.
1858
+ # Unique construction names are prefixed with the surface name,
1859
+ # and suffixed with " tbd", indicating that the construction is
1860
+ # henceforth thermally derated. The " tbd" expression is also key in
1861
+ # avoiding inadvertent derating - TBD will not derate constructions
1862
+ # (or rather layered materials) having " tbd" in their OpenStudio name.
1863
+ tbd[:surfaces].each do |id, surface|
1864
+ next unless surface.key?(:construction)
1865
+ next unless surface.key?(:index )
1866
+ next unless surface.key?(:ltype )
1867
+ next unless surface.key?(:r )
1868
+ next unless surface.key?(:edges )
1869
+ next unless surface.key?(:heatloss )
1870
+ next unless surface[:heatloss].abs > TOL
1871
+
1872
+ model.getSurfaces.each do |s|
1873
+ next unless id == s.nameString
1874
+ index = surface[:index ]
1875
+ current_c = surface[:construction]
1876
+ c = current_c.clone(model).to_LayeredConstruction.get
1877
+ m = nil
1878
+ m = derate(model, id, surface, c) if index
1879
+ # m may be nilled simply because the targeted construction has already
1880
+ # been derated, i.e. holds " tbd" in its name. Names of cloned/derated
1881
+ # constructions (due to TBD) include the surface name (since derated
1882
+ # constructions are now unique to each surface) and the suffix " c tbd".
1883
+ if m
1884
+ c.setLayer(index, m)
1885
+ c.setName("#{id} c tbd")
1886
+ current_R = rsi(current_c, s.filmResistance)
1887
+
1888
+ # In principle, the derated "ratio" could be calculated simply by
1889
+ # accessing a surface's uFactor. Yet air layers within constructions
1890
+ # (not air films) are ignored in OpenStudio's uFactor calculation.
1891
+ # An example would be 25mm-50mm pressure-equalized air gaps behind
1892
+ # brick veneer. This is not always compliant to some energy codes.
1893
+ # TBD currently factors-in air gap (and exterior cladding) R-values.
1894
+ #
1895
+ # If one comments out the following loop (3 lines), tested surfaces
1896
+ # with air layers will generate discrepencies between the calculed RSi
1897
+ # value above and the inverse of the uFactor. All other surface
1898
+ # constructions pass the test.
1899
+ #
1900
+ # if ((1/current_R) - s.uFactor.to_f).abs > 0.005
1901
+ # puts "#{s.nameString} - Usi:#{1/current_R} UFactor: #{s.uFactor}"
1902
+ # end
1903
+ s.setConstruction(c)
1904
+
1905
+ # If the derated surface construction separates CONDITIONED space from
1906
+ # UNCONDITIONED or UNENCLOSED space, then derate the adjacent surface
1907
+ # construction as well (unless defaulted).
1908
+ if s.outsideBoundaryCondition.downcase == "surface"
1909
+ unless s.adjacentSurface.empty?
1910
+ adjacent = s.adjacentSurface.get
1911
+ nom = adjacent.nameString
1912
+ default = adjacent.isConstructionDefaulted == false
1913
+
1914
+ if default && tbd[:surfaces].key?(nom)
1915
+ current_cc = tbd[:surfaces][nom][:construction]
1916
+ cc = current_cc.clone(model).to_LayeredConstruction.get
1917
+
1918
+ cc.setLayer(tbd[:surfaces][nom][:index], m)
1919
+ cc.setName("#{nom} c tbd")
1920
+ adjacent.setConstruction(cc)
1921
+ end
1922
+ end
1923
+ end
1924
+
1925
+ # Compute updated RSi value from layers.
1926
+ updated_c = s.construction.get.to_LayeredConstruction.get
1927
+ updated_R = rsi(updated_c, s.filmResistance)
1928
+ ratio = -(current_R - updated_R) * 100 / current_R
1929
+
1930
+ surface[:ratio] = ratio if ratio.abs > TOL
1931
+ surface[:u ] = 1 / current_R # un-derated U-factors (for UA')
1932
+ end
1933
+ end
1934
+ end
1935
+
1936
+ # Ensure deratable surfaces have U-factors (even if NOT derated).
1937
+ tbd[:surfaces].each do |id, surface|
1938
+ next unless surface[:deratable]
1939
+ next unless surface.key?(:construction)
1940
+ next if surface.key?(:u)
1941
+ s = model.getSurfaceByName(id)
1942
+ log(ERR, "Skipping missing surface '#{id}' (#{mth})") if s.empty?
1943
+ next if s.empty?
1944
+ surface[:u] = 1.0 / rsi(surface[:construction], s.get.filmResistance)
1945
+ end
1946
+
1947
+ json[:io][:edges] = []
1948
+ # Enrich io with TBD/Topolys edge info before returning:
1949
+ # 1. edge custom PSI set, if on file
1950
+ # 2. edge PSI type
1951
+ # 3. edge length (m)
1952
+ # 4. edge origin & end vertices
1953
+ # 5. array of linked outside- or ground-facing surfaces
1954
+ edges.values.each do |e|
1955
+ next unless e.key?(:psi)
1956
+ next unless e.key?(:set)
1957
+ v = e[:psi].values.max
1958
+ set = e[:set]
1959
+ t = e[:psi].key(v)
1960
+ l = e[:length]
1961
+ edge = { psi: set, type: t, length: l, surfaces: e[:surfaces].keys }
1962
+ edge[:v0x] = e[:v0].point.x
1963
+ edge[:v0y] = e[:v0].point.y
1964
+ edge[:v0z] = e[:v0].point.z
1965
+ edge[:v1x] = e[:v1].point.x
1966
+ edge[:v1y] = e[:v1].point.y
1967
+ edge[:v1z] = e[:v1].point.z
1968
+
1969
+ json[:io][:edges] << edge
1970
+ end
1971
+
1972
+ empty = json[:io][:edges].empty?
1973
+ json[:io][:edges].sort_by { |e| [ e[:v0x], e[:v0y], e[:v0z],
1974
+ e[:v1x], e[:v1y], e[:v1z] ] } unless empty
1975
+ json[:io].delete(:edges) if empty
1976
+
1977
+ # Populate UA' trade-off reference values (optional).
1978
+ ua = argh[:gen_ua] && argh[:ua_ref] && argh[:ua_ref] == "code (Quebec)"
1979
+ qc33(tbd[:surfaces], json[:psi], setpoints) if ua
1980
+
1981
+ tbd[:io] = json[:io]
1982
+
1983
+ tbd
1984
+ end
1985
+
1986
+ ##
1987
+ # TBD exit strategy for OpenStudio Measures. May write out TBD model
1988
+ # content/results if requested (see argh). Always writes out minimal logs,
1989
+ # (see tbd.out.json).
1990
+ #
1991
+ # @param runner [Runner] OpenStudio Measure runner
1992
+ # @param argh [Hash] TBD arguments
1993
+ #
1994
+ # @return [Bool] true if TBD Measure is successful
1995
+ def exit(runner = nil, argh = {})
1996
+ mth = "TBD::#{__callee__}"
1997
+
1998
+ # Generated files target a design context ( >= WARN ) ... change TBD log
1999
+ # level for debugging purposes. By default, log status is set < DBG
2000
+ # while log level is set @INF.
2001
+ state = msg(status)
2002
+ state = msg(INF) if status.zero?
2003
+ argh = {} unless argh.is_a?(Hash)
2004
+ argh[:io ] = nil unless argh.key?(:io)
2005
+ argh[:surfaces] = nil unless argh.key?(:surfaces)
2006
+
2007
+ unless argh[:io] && argh[:surfaces]
2008
+ state = "Halting all TBD processes, yet running OpenStudio"
2009
+ state = "Halting all TBD processes, and halting OpenStudio" if fatal?
2010
+ end
2011
+
2012
+ argh[:io ] = {} unless argh[:io]
2013
+ argh[:seed ] = "" unless argh.key?(:seed )
2014
+ argh[:version ] = "" unless argh.key?(:version )
2015
+ argh[:gen_ua ] = false unless argh.key?(:gen_ua )
2016
+ argh[:ua_ref ] = "" unless argh.key?(:ua_ref )
2017
+ argh[:setpoints ] = false unless argh.key?(:setpoints )
2018
+ argh[:write_tbd ] = false unless argh.key?(:write_tbd )
2019
+ argh[:uprate_walls ] = false unless argh.key?(:uprate_walls )
2020
+ argh[:uprate_roofs ] = false unless argh.key?(:uprate_roofs )
2021
+ argh[:uprate_floors] = false unless argh.key?(:uprate_floors)
2022
+ argh[:wall_ut ] = 5.678 unless argh.key?(:wall_ut )
2023
+ argh[:roof_ut ] = 5.678 unless argh.key?(:roof_ut )
2024
+ argh[:floor_ut ] = 5.678 unless argh.key?(:floor_ut )
2025
+ argh[:wall_option ] = "" unless argh.key?(:wall_option )
2026
+ argh[:roof_option ] = "" unless argh.key?(:roof_option )
2027
+ argh[:floor_option ] = "" unless argh.key?(:floor_option )
2028
+ argh[:wall_uo ] = nil unless argh.key?(:wall_ut )
2029
+ argh[:roof_uo ] = nil unless argh.key?(:roof_ut )
2030
+ argh[:floor_uo ] = nil unless argh.key?(:floor_ut )
2031
+
2032
+ groups = { wall: {}, roof: {}, floor: {} }
2033
+ groups[:wall ][:up] = argh[:uprate_walls ]
2034
+ groups[:roof ][:up] = argh[:uprate_roofs ]
2035
+ groups[:floor][:up] = argh[:uprate_floors]
2036
+ groups[:wall ][:ut] = argh[:wall_ut ]
2037
+ groups[:roof ][:ut] = argh[:roof_ut ]
2038
+ groups[:floor][:ut] = argh[:floor_ut ]
2039
+ groups[:wall ][:op] = argh[:wall_option ]
2040
+ groups[:roof ][:op] = argh[:roof_option ]
2041
+ groups[:floor][:op] = argh[:floor_option ]
2042
+ groups[:wall ][:uo] = argh[:wall_uo ]
2043
+ groups[:roof ][:uo] = argh[:roof_uo ]
2044
+ groups[:floor][:uo] = argh[:floor_uo ]
2045
+
2046
+ io = argh[:io ]
2047
+ out = argh[:write_tbd]
2048
+ descr = ""
2049
+ descr = argh[:seed] unless argh[:seed].empty?
2050
+ io[:description] = descr unless io.key?(:description)
2051
+ descr = io[:description]
2052
+
2053
+ schema_pth = "https://github.com/rd2/tbd/blob/master/tbd.schema.json"
2054
+ io[:schema] = schema_pth unless io.key?(:schema)
2055
+ tbd_log = { date: Time.now, status: state }
2056
+ u_t = []
2057
+
2058
+ groups.each do |label, g|
2059
+ next if fatal?
2060
+ next unless g[:uo]
2061
+ next unless g[:uo].is_a?(Numeric)
2062
+
2063
+ uo = format("%.3f", g[:uo])
2064
+ ut = format("%.3f", g[:ut])
2065
+ output = "An initial #{label.to_s} Uo of #{uo} W/m2•K is required to " \
2066
+ "achieve an overall Ut of #{ut} W/m2•K for #{g[:op]}"
2067
+ u_t << output
2068
+ runner.registerInfo(output)
2069
+ end
2070
+
2071
+ tbd_log[:ut] = u_t unless u_t.empty?
2072
+ ua_md_en = nil
2073
+ ua_md_fr = nil
2074
+ ua = nil
2075
+ ok = argh[:surfaces] && argh[:gen_ua]
2076
+ ua = ua_summary(tbd_log[:date], argh) if ok
2077
+
2078
+ unless fatal? || ua.nil? || ua.empty?
2079
+ if ua.key?(:en)
2080
+ if ua[:en].key?(:b1) || ua[:en].key?(:b2)
2081
+ runner.registerInfo("-")
2082
+ runner.registerInfo(ua[:model])
2083
+ tbd_log[:ua] = {}
2084
+ ua_md_en = ua_md(ua, :en)
2085
+ ua_md_fr = ua_md(ua, :fr)
2086
+ end
2087
+
2088
+ if ua[:en].key?(:b1) && ua[:en][:b1].key?(:summary)
2089
+ runner.registerInfo(" - #{ua[:en][:b1][:summary]}")
2090
+
2091
+ ua[:en][:b1].each do |k, v|
2092
+ runner.registerInfo(" --- #{v}") unless k == :summary
2093
+ end
2094
+
2095
+ tbd_log[:ua][:bloc1] = ua[:en][:b1]
2096
+ end
2097
+
2098
+ if ua[:en].key?(:b2) && ua[:en][:b2].key?(:summary)
2099
+ runner.registerInfo(" - #{ua[:en][:b2][:summary]}")
2100
+
2101
+ ua[:en][:b2].each do |k, v|
2102
+ runner.registerInfo(" --- #{v}") unless k == :summary
2103
+ end
2104
+
2105
+ tbd_log[:ua][:bloc2] = ua[:en][:b2]
2106
+ end
2107
+ end
2108
+
2109
+ runner.registerInfo(" -")
2110
+ end
2111
+
2112
+ results = []
2113
+
2114
+ if argh[:surfaces]
2115
+ argh[:surfaces].each do |id, surface|
2116
+ next if fatal?
2117
+ next unless surface.key?(:ratio)
2118
+ ratio = format("%4.1f", surface[:ratio])
2119
+ output = "RSi derated by #{ratio}% : #{id}"
2120
+
2121
+ results << output
2122
+ runner.registerInfo(output)
2123
+ end
2124
+ end
2125
+
2126
+ tbd_log[:results] = results unless results.empty?
2127
+ tbd_msgs = []
2128
+
2129
+ logs.each do |l|
2130
+ tbd_msgs << { level: tag(l[:level]), message: l[:message] }
2131
+
2132
+ runner.registerWarning(l[:message]) if l[:level] > INF
2133
+ runner.registerInfo(l[:message]) if l[:level] <= INF
2134
+ end
2135
+
2136
+ tbd_log[:messages] = tbd_msgs unless tbd_msgs.empty?
2137
+ io[:log] = tbd_log
2138
+
2139
+ # User's may not be requesting detailed output - delete non-essential items.
2140
+ io.delete(:psis ) unless out
2141
+ io.delete(:khis ) unless out
2142
+ io.delete(:building ) unless out
2143
+ io.delete(:stories ) unless out
2144
+ io.delete(:spacetypes) unless out
2145
+ io.delete(:spaces ) unless out
2146
+ io.delete(:surfaces ) unless out
2147
+ io.delete(:edges ) unless out
2148
+
2149
+ # Deterministic sorting
2150
+ io[:schema ] = io.delete(:schema ) if io.key?(:schema )
2151
+ io[:description] = io.delete(:description) if io.key?(:description)
2152
+ io[:log ] = io.delete(:log ) if io.key?(:log )
2153
+ io[:psis ] = io.delete(:psis ) if io.key?(:psis )
2154
+ io[:khis ] = io.delete(:khis ) if io.key?(:khis )
2155
+ io[:building ] = io.delete(:building ) if io.key?(:building )
2156
+ io[:stories ] = io.delete(:stories ) if io.key?(:stories )
2157
+ io[:spacetypes ] = io.delete(:spacetypes ) if io.key?(:spacetypes )
2158
+ io[:spaces ] = io.delete(:spaces ) if io.key?(:spaces )
2159
+ io[:surfaces ] = io.delete(:surfaces ) if io.key?(:surfaces )
2160
+ io[:edges ] = io.delete(:edges ) if io.key?(:edges )
2161
+
2162
+ out_dir = '.'
2163
+ file_paths = runner.workflow.absoluteFilePaths
2164
+
2165
+ # 'Apply Measure Now' won't cp files from 1st path back to generated_files.
2166
+ match1 = /WorkingFiles/.match(file_paths[1].to_s)
2167
+ match2 = /files/.match(file_paths[1].to_s)
2168
+ match = match1 || match2
2169
+
2170
+ if file_paths.size >= 2 && File.exists?(file_paths[1].to_s) && match
2171
+ out_dir = file_paths[1].to_s
2172
+ elsif !file_paths.empty? && File.exists?(file_paths.first.to_s)
2173
+ out_dir = file_paths.first.to_s
2174
+ end
2175
+
2176
+ out_path = File.join(out_dir, "tbd.out.json")
2177
+
2178
+ File.open(out_path, 'w') do |file|
2179
+ file.puts JSON::pretty_generate(io)
2180
+ # Make sure data is written to the disk one way or the other.
2181
+ begin
2182
+ file.fsync
2183
+ rescue StandardError
2184
+ file.flush
2185
+ end
2186
+ end
2187
+
2188
+ unless TBD.fatal? || ua.nil? || ua.empty?
2189
+ unless ua_md_en.nil? || ua_md_en.empty?
2190
+ ua_path = File.join(out_dir, "ua_en.md")
2191
+
2192
+ File.open(ua_path, 'w') do |file|
2193
+ file.puts ua_md_en
2194
+
2195
+ begin
2196
+ file.fsync
2197
+ rescue StandardError
2198
+ file.flush
2199
+ end
2200
+ end
2201
+ end
2202
+
2203
+ unless ua_md_fr.nil? || ua_md_fr.empty?
2204
+ ua_path = File.join(out_dir, "ua_fr.md")
2205
+
2206
+ File.open(ua_path, 'w') do |file|
2207
+ file.puts ua_md_fr
2208
+
2209
+ begin
2210
+ file.fsync
2211
+ rescue StandardError
2212
+ file.flush
2213
+ end
2214
+ end
2215
+ end
2216
+ end
2217
+
2218
+ if fatal?
2219
+ runner.registerError("#{state} - see 'tbd.out.json'")
2220
+ return false
2221
+ elsif error? || warn?
2222
+ runner.registerWarning("#{state} - see 'tbd.out.json'")
2223
+ return true
2224
+ else
2225
+ runner.registerInfo("#{state} - see 'tbd.out.json'")
2226
+ return true
2227
+ end
2228
+ end
2229
+ end