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,986 @@
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
+ # Calculates construction Uo (including surface film resistances) to meet Ut.
26
+ #
27
+ # @param model [OpenStudio::Model::Model] a model
28
+ # @param lc [OpenStudio::Model::LayeredConstruction] a layered construction
29
+ # @param id [String] layered construction identifier
30
+ # @param heatloss [Double] heat loss from major thermal bridging [W/K]
31
+ # @param film [Double] target surface film resistance [m2.K/W]
32
+ # @param ut [Double] target overall Ut for lc [W/m2.K]
33
+ #
34
+ # @return [Hash] uo: lc Uo [W/m2.K] to meet Ut, m: uprated lc layer
35
+ # @return [Hash] uo: NilClass, m: NilClass (if invalid input)
36
+ def uo(model = nil, lc = nil, id = "", hloss = 0.0, film = 0.0, ut = 0.0)
37
+ mth = "TBD::#{__callee__}"
38
+ res = { uo: nil, m: nil }
39
+ cl1 = OpenStudio::Model::Model
40
+ cl2 = OpenStudio::Model::LayeredConstruction
41
+ cl3 = Numeric
42
+
43
+ return mismatch("model", model, cl1, mth, DBG, res) unless model.is_a?(cl1)
44
+ return mismatch("id", id, String, mth, DBG, res) unless id.is_a?(String)
45
+ return mismatch("lc", lc, cl2, mth, DBG, res) unless lc.is_a?(cl2)
46
+ return mismatch("hloss", hloss, cl3, mth, DBG, res) unless hloss.is_a?(cl3)
47
+ return mismatch("film", film, cl3, mth, DBG, res) unless film.is_a?(cl3)
48
+ return mismatch("Ut", ut, cl3, mth, DBG, res) unless ut.is_a?(cl3)
49
+
50
+ lyr = insulatingLayer(lc)
51
+ lyr[:index] = nil unless lyr[:index].is_a?(Numeric)
52
+ lyr[:index] = nil unless lyr[:index] >= 0
53
+ lyr[:index] = nil unless lyr[:index] < lc.layers.size
54
+
55
+ return invalid("'#{id}' layer index", mth, 0, ERR, res) unless lyr[:index]
56
+ return zero("'#{id}': heatloss", mth, WRN, res) unless hloss > TOL
57
+ return zero("'#{id}': films", mth, WRN, res) unless film > TOL
58
+ return zero("'#{id}': Ut", mth, WRN, res) unless ut > TOL
59
+ return invalid("'#{id}': Ut", mth, 0, WRN, res) unless ut < 5.678
60
+
61
+ area = lc.getNetArea
62
+ return zero("'#{id}': net area (m2)", mth, ERR, res) unless area > TOL
63
+
64
+ # First, calculate initial layer RSi to initially meet Ut target.
65
+ rt = 1 / ut # target construction Rt
66
+ ro = rsi(lc, film) # current construction Ro
67
+ new_r = lyr[:r] + (rt - ro) # new, un-derated layer RSi
68
+ new_u = 1 / new_r
69
+
70
+ # Then, uprate (if possible) to counter expected thermal bridging effects.
71
+ u_psi = hloss / area # from psi & khi
72
+ new_u = new_u - u_psi # uprated layer USi to counter psi & khi
73
+ new_r = 1 / new_u # uprated layer RSi to counter psi & khi
74
+
75
+ return zero("'#{id}': new Rsi", mth, ERR, res) unless new_r > 0.001
76
+ loss = 0.0 # residual heatloss (not assigned) [W/K]
77
+
78
+ if lyr[:type] == :massless
79
+ m = lc.getLayer(lyr[:index]).to_MasslessOpaqueMaterial
80
+ return invalid("'#{id}' massless layer?", mth, 0) if m.empty?
81
+ m = m.get.clone(model).to_MasslessOpaqueMaterial.get
82
+ m.setName("#{id} uprated")
83
+ new_r = 0.001 unless new_r > 0.001
84
+ loss = (new_u - 1 / new_r) * area unless new_r > 0.001
85
+ m.setThermalResistance(new_r)
86
+ else # type == :standard
87
+ m = lc.getLayer(lyr[:index]).to_StandardOpaqueMaterial
88
+ return invalid("'#{id}' standard layer?", mth, 0) if m.empty?
89
+ m = m.get.clone(model).to_StandardOpaqueMaterial.get
90
+ m.setName("#{id} uprated")
91
+ k = m.thermalConductivity
92
+
93
+ if new_r > 0.001
94
+ d = new_r * k
95
+
96
+ unless d > 0.003
97
+ d = 0.003
98
+ k = d / new_r
99
+ k = 3.0 unless k < 3.0
100
+ loss = (new_u - k / d) * area unless k < 3.0
101
+ end
102
+ else # new_r < 0.001 m2.K/W
103
+ d = 0.001 * k
104
+ d = 0.003 unless d > 0.003
105
+ k = d / 0.001 unless d > 0.003
106
+ loss = (new_u - k / d) * area
107
+ end
108
+
109
+ ok = m.setThickness(d)
110
+ return invalid("Can't uprate '#{id}': > 3m", mth, 0, ERR, res) unless ok
111
+ m.setThermalConductivity(k) if ok
112
+ end
113
+
114
+ return invalid("", mth, 0, ERR, res) unless m
115
+ lc.setLayer(lyr[:index], m)
116
+ uo = 1 / rsi(lc, film)
117
+
118
+ if loss > TOL
119
+ h_loss = format "%.3f", loss
120
+ return invalid("Can't assign #{h_loss} W/K to '#{id}'", mth, 0, ERR, res)
121
+ end
122
+
123
+ res[:uo] = uo
124
+ res[:m ] = m
125
+
126
+ res
127
+ end
128
+
129
+ ##
130
+ # Uprate insulation layer of construction, based on user-selected Ut (argh).
131
+ #
132
+ # @param model [OpenStudio::Model::Model] a model
133
+ # @param s [Hash] preprocessed collection of TBD surfaces
134
+ # @param argh [Hash] TBD arguments
135
+ #
136
+ # @return [Bool] true if successfully uprated
137
+ def uprate(model = nil, s = {}, argh = {})
138
+ mth = "TBD::#{__callee__}"
139
+ cl1 = OpenStudio::Model::Model
140
+ cl2 = Hash
141
+ a = false
142
+
143
+ return mismatch("model", model, cl1, mth, DBG, a) unless model.is_a?(cl1)
144
+ return mismatch("surfaces", s, cl2, mth, DBG, a) unless s.is_a?(cl2)
145
+ return mismatch("argh", model, cl1, mth, DBG, a) unless argh.is_a?(cl2)
146
+
147
+ argh[:uprate_walls ] = false unless argh.key?(:uprate_walls )
148
+ argh[:uprate_roofs ] = false unless argh.key?(:uprate_roofs )
149
+ argh[:uprate_floors] = false unless argh.key?(:uprate_floors)
150
+ argh[:wall_ut ] = 5.678 unless argh.key?(:wall_ut )
151
+ argh[:roof_ut ] = 5.678 unless argh.key?(:roof_ut )
152
+ argh[:floor_ut ] = 5.678 unless argh.key?(:floor_ut )
153
+ argh[:wall_option ] = "" unless argh.key?(:wall_option )
154
+ argh[:roof_option ] = "" unless argh.key?(:roof_option )
155
+ argh[:floor_option ] = "" unless argh.key?(:floor_option )
156
+
157
+ groups = { wall: {}, roof: {}, floor: {} }
158
+ groups[:wall ][:up] = argh[:uprate_walls ]
159
+ groups[:roof ][:up] = argh[:uprate_roofs ]
160
+ groups[:floor][:up] = argh[:uprate_floors]
161
+ groups[:wall ][:ut] = argh[:wall_ut ]
162
+ groups[:roof ][:ut] = argh[:roof_ut ]
163
+ groups[:floor][:ut] = argh[:floor_ut ]
164
+ groups[:wall ][:op] = argh[:wall_option ]
165
+ groups[:roof ][:op] = argh[:roof_option ]
166
+ groups[:floor][:op] = argh[:floor_option ]
167
+
168
+ groups.each do |label, g|
169
+ next unless g[:up]
170
+ next unless g[:ut].is_a?(Numeric)
171
+ next unless g[:ut] < 5.678
172
+
173
+ coll = {}
174
+ area = 0
175
+ film = 100000000000000
176
+ lc = nil
177
+ uo = nil
178
+ id = ""
179
+ all = g[:op].downcase == "all wall constructions" ||
180
+ g[:op].downcase == "all roof constructions" ||
181
+ g[:op].downcase == "all floor constructions"
182
+
183
+ if g[:op].empty?
184
+ log(ERR, "Construction to uprate? (#{mth})")
185
+ elsif all
186
+ model.getSurfaces.each do |sss|
187
+ next unless sss.surfaceType.downcase.include?(label.to_s)
188
+ next unless sss.outsideBoundaryCondition.downcase == "outdoors"
189
+ next if sss.construction.empty?
190
+ next if sss.construction.get.to_LayeredConstruction.empty?
191
+ c = sss.construction.get.to_LayeredConstruction.get
192
+ i = c.nameString
193
+
194
+ # Reliable unless referenced by other surface types e.g. floor vs wall.
195
+ if c.getNetArea > area
196
+ area = c.getNetArea
197
+ lc = c
198
+ id = i
199
+ end
200
+
201
+ film = sss.filmResistance if sss.filmResistance < film
202
+ nom = sss.nameString
203
+ coll[i] = { area: c.getNetArea, lc: c, s: {} } unless coll.key?(i)
204
+ coll[i][:s][nom] = { a: sss.netArea } unless coll[i][:s].key?(nom)
205
+ end
206
+ else
207
+ id = g[:op]
208
+ c = model.getConstructionByName(id)
209
+
210
+ if c.empty?
211
+ log(ERR, "Construction '#{id}'? (#{mth})")
212
+ else
213
+ c = c.get.to_LayeredConstruction
214
+
215
+ if c.empty?
216
+ log(ERR, "'#{id}' layered construction? (#{mth})")
217
+ else
218
+ lc = c.get
219
+ area = lc.getNetArea
220
+ coll[id] = { area: area, lc: lc, s: {} }
221
+
222
+ model.getSurfaces.each do |sss|
223
+ next unless sss.surfaceType.downcase.include?(label.to_s)
224
+ next unless sss.outsideBoundaryCondition.downcase == "outdoors"
225
+ next if sss.construction.empty?
226
+ next if sss.construction.get.to_LayeredConstruction.empty?
227
+ lc = sss.construction.get.to_LayeredConstruction.get
228
+ next unless id == lc.nameString
229
+ nom = sss.nameString
230
+ film = sss.filmResistance if sss.filmResistance < film
231
+ ok = coll[id][:s].key?(nom)
232
+ coll[id][:s][nom] = { a: sss.netArea } unless ok
233
+ end
234
+ end
235
+ end
236
+ end
237
+
238
+ if coll.empty?
239
+ log(ERR, "No construction to uprate - skipping (#{mth})")
240
+ next
241
+ elsif lc # valid layered construction - good to uprate!
242
+ # Ensure lc is referenced by surface types == label.
243
+ model.getSurfaces.each do |sss|
244
+ next if sss.construction.empty?
245
+ next if sss.construction.get.to_LayeredConstruction.empty?
246
+ c = sss.construction.get.to_LayeredConstruction.get
247
+ i = c.nameString
248
+ next unless coll.key?(i)
249
+
250
+ unless sss.surfaceType.downcase.include?(label.to_s)
251
+ log(ERR, "Uprating #{label.to_s}, not '#{sss.nameString}' (#{mth})")
252
+ cloned = c.clone(model).to_LayeredConstruction.get
253
+ cloned.setName("'#{i}' cloned")
254
+ sss.setConstruction(cloned)
255
+ ok = s.key?(sss.nameString)
256
+ s[sss.nameString][:construction] = cloned if ok
257
+ coll[i][:s].delete(sss.nameString)
258
+ coll[i][:area] = c.getNetArea
259
+ next
260
+ end
261
+ end
262
+
263
+ lyr = insulatingLayer(lc)
264
+ lyr[:index] = nil unless lyr[:index].is_a?(Numeric)
265
+ lyr[:index] = nil unless lyr[:index] >= 0
266
+ lyr[:index] = nil unless lyr[:index] < lc.layers.size
267
+
268
+ log(ERR, "Insulation index for '#{id}'? (#{mth})") unless lyr[:index]
269
+ next unless lyr[:index]
270
+ hloss = 0 # sum of applicable psi & khi effects [W/K]
271
+
272
+ coll.each do |i, col|
273
+ next unless col.key?(:s)
274
+ next unless col.is_a?(Hash)
275
+
276
+ col[:s].keys.each do |nom|
277
+ next unless s.key?(nom)
278
+ next unless s[nom].key?(:deratable )
279
+ next unless s[nom].key?(:construction)
280
+ next unless s[nom].key?(:index )
281
+ next unless s[nom].key?(:ltype )
282
+ next unless s[nom].key?(:r )
283
+ next unless s[nom].key?(:type )
284
+
285
+ next unless s[nom][:deratable]
286
+ type = s[nom][:type].to_s.downcase
287
+ type = "roof" if type == "ceiling"
288
+ next unless type.include?(label.to_s)
289
+
290
+ # Tally applicable psi + khi.
291
+ hloss += s[nom][:heatloss] if s[nom].key?(:heatloss)
292
+
293
+ # Skip construction reassignment if already referencing right one.
294
+ unless s[nom][:construction] == lc
295
+ sss = model.getSurfaceByName(nom)
296
+ next if sss.empty?
297
+ sss = sss.get
298
+
299
+ if sss.isConstructionDefaulted
300
+ set = defaultConstructionSet(model, sss)
301
+ constructions = set.defaultExteriorSurfaceConstructions.get
302
+
303
+ case sss.surfaceType.downcase
304
+ when "roofceiling"
305
+ constructions.setRoofCeilingConstruction(lc)
306
+ when "floor"
307
+ constructions.setFloorConstruction(lc)
308
+ else
309
+ constructions.setWallConstruction(lc)
310
+ end
311
+ else
312
+ sss.setConstruction(lc)
313
+ end
314
+
315
+ s[nom][:construction] = lc # reset TBD attributes
316
+ s[nom][:index ] = lyr[:index]
317
+ s[nom][:ltype ] = lyr[:type ]
318
+ s[nom][:r ] = lyr[:r ] # temporary
319
+ end
320
+ end
321
+ end
322
+
323
+ # Merge to ensure a single entry for coll Hash.
324
+ coll.each do |i, col|
325
+ next if i == id
326
+ next unless coll.key?(id)
327
+ coll[id][:area] += col[:area]
328
+
329
+ col[:s].each do |nom, sss|
330
+ coll[id][:s][nom] = sss unless coll[id][:s].key?(nom)
331
+ end
332
+ end
333
+
334
+ coll.delete_if { |i, _| i != id }
335
+ log(DBG, "Collection == 1? for '#{id}' (#{mth})") unless coll.size == 1
336
+ next unless coll.size == 1
337
+
338
+ res = uo(model, lc, id, hloss, film, g[:ut])
339
+ log(ERR, "Unable to uprate '#{id}' (#{mth})") unless res[:uo] && res[:m]
340
+ next unless res[:uo] && res[:m]
341
+
342
+ lyr = insulatingLayer(lc)
343
+
344
+ # Loop through coll :s, and reset :r - likely modified by uo().
345
+ coll.values.first[:s].keys.each do |nom|
346
+ next unless s.key?(nom)
347
+ next unless s[nom].key?(:deratable )
348
+ next unless s[nom].key?(:construction)
349
+ next unless s[nom].key?(:index )
350
+ next unless s[nom].key?(:ltype )
351
+ next unless s[nom].key?(:type )
352
+
353
+ next unless s[nom][:deratable ]
354
+ next unless s[nom][:construction] == lc
355
+ next unless s[nom][:index ] == lyr[:index]
356
+ next unless s[nom][:ltype ] == lyr[:type]
357
+
358
+ type = s[nom][:type].to_s.downcase
359
+ type = "roof" if type == "ceiling"
360
+ next unless type.include?(label.to_s)
361
+ next unless s[nom].key?(:r)
362
+ s[nom][:r] = lyr[:r] # final
363
+ end
364
+
365
+ argh[:wall_uo ] = res[:uo] if label == :wall
366
+ argh[:roof_uo ] = res[:uo] if label == :roof
367
+ argh[:floor_uo] = res[:uo] if label == :floor
368
+ else
369
+ log(ERR, "Nilled construction to uprate - (#{mth})")
370
+ return false
371
+ end
372
+ end
373
+
374
+ true
375
+ end
376
+
377
+ ##
378
+ # Set reference values for points, edges & surfaces (& subsurfaces) to
379
+ # compute Quebec energy code (Section 3.3) UA' comparison (2021).
380
+ #
381
+ # @param s [Hash] preprocessed collection of TBD surfaces
382
+ # @param sets [TBD::PSI] a TBD model's PSI sets
383
+ # @param spts [Bool] true if OpenStudio model has valid setpoints
384
+ #
385
+ # @return [Bool] true if successful in generating UA' reference values
386
+ def qc33(s = {}, sets = nil, spts = true)
387
+ mth = "TBD::#{__callee__}"
388
+ cl1 = Hash
389
+ cl2 = TBD::PSI
390
+
391
+ return mismatch("surfaces", s, cl1, mth, DBG, false) unless s.is_a?(cl1)
392
+ return mismatch("sets", sets, cl1, mth, DBG, false) unless sets.is_a?(cl2)
393
+
394
+ shorts = sets.shorthands("code (Quebec)")
395
+ empty = shorts[:has].empty? || shorts[:val].empty?
396
+ log(DBG, "Missing QC PSI set for 3.3 UA' tradeoff (#{mth})") if empty
397
+ return false if empty
398
+
399
+ ok = spts == true || spts == false
400
+ log(DBG, "'setpoints' must be true/false for 3.3 UA' tradeoff") unless ok
401
+ return false unless ok
402
+
403
+ s.each do |id, surface|
404
+ next unless surface.key?(:deratable)
405
+ next unless surface[:deratable]
406
+ next unless surface.key?(:type)
407
+
408
+ heating = -50 if spts
409
+ cooling = 50 if spts
410
+ heating = 21 unless spts
411
+ cooling = 24 unless spts
412
+ heating = surface[:heating] if surface.key?(:heating)
413
+ cooling = surface[:cooling] if surface.key?(:cooling)
414
+
415
+ # Start with surface U-factors.
416
+ ref = 1 / 5.46
417
+ ref = 1 / 3.60 if surface[:type] == :wall
418
+
419
+ # Adjust for lower heating setpoint (assumes -25°C design conditions).
420
+ ref *= 43 / (heating + 25) if heating < 18 && cooling > 40
421
+ surface[:ref] = ref # ... and store
422
+
423
+ if surface.key?(:skylights) # loop through subsurfaces
424
+ ref = 2.85
425
+ ref *= 43 / (heating + 25) if heating < 18 && cooling > 40
426
+ surface[:skylights].values.map { |skylight| skylight[:ref] = ref }
427
+ end
428
+
429
+ if surface.key?(:windows)
430
+ ref = 2.0
431
+ ref *= 43 / (heating + 25) if heating < 18 && cooling > 40
432
+ surface[:windows].values.map { |window| window[:ref] = ref }
433
+ end
434
+
435
+ if surface.key?(:doors)
436
+ surface[:doors].each do |i, door|
437
+ ref = 0.9
438
+ ref = 2.0 if door.key?(:glazed) && door[:glazed]
439
+ ref *= 43 / (heating + 25) if heating < 18 && cooling > 40
440
+ door[:ref] = ref
441
+ end
442
+ end
443
+
444
+ # Loop through point thermal bridges.
445
+ surface[:pts].map { |i, pt| pt[:ref] = 0.5 } if surface.key?(:pts)
446
+
447
+ # Loop through linear thermal bridges.
448
+ if surface.key?(:edges)
449
+ surface[:edges].values.each do |edge|
450
+ next unless edge.key?(:type)
451
+ next unless edge.key?(:ratio)
452
+ safe = sets.safe("code (Quebec)", edge[:type])
453
+ edge[:ref] = shorts[:val][safe] * edge[:ratio] if safe
454
+ end
455
+ end
456
+ end
457
+
458
+ true
459
+ end
460
+
461
+ ##
462
+ # Generate UA' summary.
463
+ #
464
+ # @param date [Time] Time stamp
465
+ # @param argh [Hash] TBD arguments
466
+ #
467
+ # @return [Hash] multilingual binned values for UA' summary
468
+ def ua_summary(date = Time.now, argh = {})
469
+ mth = "TBD::#{__callee__}"
470
+
471
+ ua = {}
472
+ argh = {} unless argh.is_a?(Hash )
473
+ argh[:seed ] = "" unless argh.key?(:seed )
474
+ argh[:ua_ref ] = "" unless argh.key?(:ua_ref )
475
+ argh[:surfaces ] = nil unless argh.key?(:surfaces )
476
+ argh[:version ] = "" unless argh.key?(:version )
477
+ argh[:io ] = {} unless argh.key?(:io )
478
+ argh[:io][:description] = "" unless argh[:io].key?(:description)
479
+
480
+ descr = argh[:io][:description]
481
+ file = argh[:seed ]
482
+ version = argh[:version ]
483
+ s = argh[:surfaces ]
484
+
485
+ return mismatch("TBD surfaces", s, Hash, mth, DBG, ua) unless s.is_a?(Hash)
486
+ return empty("TBD Surfaces", mth, WRN, ua) if s.empty?
487
+
488
+ ua[:descr ] = ""
489
+ ua[:file ] = ""
490
+ ua[:version] = ""
491
+ ua[:model ] = "∑U•A + ∑PSI•L + ∑KHI•n"
492
+ ua[:date ] = date
493
+ ua[:descr ] = descr unless descr.nil? || descr.empty?
494
+ ua[:file ] = file unless file.nil? || file.empty?
495
+ ua[:version] = version unless version.nil? || version.empty?
496
+
497
+ [:en, :fr].each { |lang| ua[lang] = {} }
498
+
499
+ ua[:en][:notes] = "Automated assessment from the OpenStudio Measure, " \
500
+ "Thermal Bridging and Derating (TBD). Open source and MIT-licensed, " \
501
+ "TBD is provided as is (without warranty). Procedures are documented " \
502
+ "in the source code: https://github.com/rd2/tbd. "
503
+
504
+ ua[:fr][:notes] = "Analyse automatisée à partir de la measure OpenStudio, "\
505
+ "'Thermal Bridging and Derating' (ou TBD). Distribuée librement " \
506
+ "(licence MIT), TBD est offerte telle quelle (sans garantie). " \
507
+ "L'approche est documentée au sein du code source : " \
508
+ "https://github.com/rd2/tbd."
509
+
510
+ walls = { net: 0, gross: 0, subs: 0 }
511
+ roofs = { net: 0, gross: 0, subs: 0 }
512
+ floors = { net: 0, gross: 0, subs: 0 }
513
+ areas = { walls: walls, roofs: roofs, floors: floors }
514
+ has = {}
515
+ val = {}
516
+ psi = PSI.new
517
+
518
+ unless argh[:ua_ref].empty?
519
+ shorts = psi.shorthands(argh[:ua_ref])
520
+ empty = shorts[:has].empty? && shorts[:val].empty?
521
+ has = shorts[:has] unless empty
522
+ val = shorts[:val] unless empty
523
+ log(ERR, "Invalid UA' reference set (#{mth})") if empty
524
+
525
+ unless empty
526
+ ua[:model] += " : Design vs '#{argh[:ua_ref]}'"
527
+
528
+ case argh[:ua_ref]
529
+ when "code (Quebec)"
530
+ ua[:en][:objective] = "COMPLIANCE ASSESSMENT"
531
+ ua[:en][:details ] = []
532
+ ua[:en][:details ] << "Quebec Construction Code, Chapter I.1"
533
+ ua[:en][:details ] << "NECB 2015, modified version (2020)"
534
+ ua[:en][:details ] << "Division B, Section 3.3"
535
+ ua[:en][:details ] << "Building Envelope Trade-off Path"
536
+
537
+ ua[:en][:notes] << " Calculations comply with Section 3.3 " \
538
+ "requirements. Results are based on user input not subject to " \
539
+ "prior validation (see DESCRIPTION), and as such the assessment " \
540
+ "shall not be considered as a certification of compliance."
541
+
542
+ ua[:fr][:objective] = "ANALYSE DE CONFORMITÉ"
543
+ ua[:fr][:details ] = []
544
+ ua[:fr][:details ] << "Code de construction du Québec, Chapitre I.1"
545
+ ua[:fr][:details ] << "CNÉB 2015, version modifiée (2020)"
546
+ ua[:fr][:details ] << "Division B, Section 3.3"
547
+ ua[:fr][:details ] << "Méthode des solutions de remplacement"
548
+
549
+ ua[:fr][:notes] << " Les calculs sont conformes aux dispositions de "\
550
+ "la Section 3.3. Les résultats sont tributaires d'intrants " \
551
+ "fournis par l'utilisateur, sans validation préalable (voir " \
552
+ "DESCRIPTION). Ce document ne peut constituer une attestation de " \
553
+ "conformité."
554
+ else
555
+ ua[:en][:objective] = "UA'"
556
+ ua[:fr][:objective] = "UA'"
557
+ end
558
+ end
559
+ end
560
+
561
+ # Set up 2x heating setpoint (HSTP) "blocks" (or bins):
562
+ # bloc1: spaces/zones with HSTP >= 18°C
563
+ # bloc2: spaces/zones with HSTP < 18°C
564
+ # (ref: 2021 Quebec energy code 3.3. UA' trade-off methodology)
565
+ # (... can be extended in the future to cover other standards)
566
+ #
567
+ # Determine UA' compliance separately for (i) bloc1 & (ii) bloc2.
568
+ #
569
+ # Each block's UA' = ∑ U•area + ∑ PSI•length + ∑ KHI•count
570
+ blc = { walls: 0, roofs: 0, floors: 0, doors: 0,
571
+ windows: 0, skylights: 0, rimjoists: 0, parapets: 0,
572
+ trim: 0, corners: 0, balconies: 0, grade: 0,
573
+ other: 0 } # includes party wall edges, expansion joints, etc.
574
+
575
+ b1 = {}
576
+ b2 = {}
577
+ b1[:pro] = blc # proposed design
578
+ b1[:ref] = blc.clone # reference
579
+ b2[:pro] = blc.clone # proposed design
580
+ b2[:ref] = blc.clone # reference
581
+
582
+ # Loop through surfaces, subsurfaces and edges and populate bloc1 & bloc2.
583
+ argh[:surfaces].each do |id, surface|
584
+ next unless surface.key?(:deratable)
585
+ next unless surface[:deratable]
586
+ next unless surface.key?(:type)
587
+ type = surface[:type]
588
+ next unless type == :wall || type == :ceiling || type == :floor
589
+ next unless surface.key?(:net)
590
+ next unless surface[:net] > TOL
591
+ next unless surface.key?(:u)
592
+ next unless surface[:u] > TOL
593
+ heating = 21.0
594
+ heating = surface[:heating] if surface.key?(:heating)
595
+ bloc = b1
596
+ bloc = b2 if heating < 18
597
+
598
+ reference = surface.key?(:ref)
599
+ if type == :wall
600
+ areas[:walls][:net ] += surface[:net]
601
+ bloc[:pro][:walls ] += surface[:net] * surface[:u ]
602
+ bloc[:ref][:walls ] += surface[:net] * surface[:ref] if reference
603
+ bloc[:ref][:walls ] += surface[:net] * surface[:u ] unless reference
604
+ elsif type == :ceiling
605
+ areas[:roofs][:net ] += surface[:net]
606
+ bloc[:pro][:roofs ] += surface[:net] * surface[:u ]
607
+ bloc[:ref][:roofs ] += surface[:net] * surface[:ref] if reference
608
+ bloc[:ref][:roofs ] += surface[:net] * surface[:u ] unless reference
609
+ else
610
+ areas[:floors][:net] += surface[:net]
611
+ bloc[:pro][:floors] += surface[:net] * surface[:u ]
612
+ bloc[:ref][:floors] += surface[:net] * surface[:ref] if reference
613
+ bloc[:ref][:floors] += surface[:net] * surface[:u ] unless reference
614
+ end
615
+
616
+ if surface.key?(:doors)
617
+ surface[:doors].values.each do |door|
618
+ next unless door.key?(:gross)
619
+ next unless door[:gross] > TOL
620
+ next unless door.key?(:u)
621
+ next unless door[:u] > TOL
622
+ areas[:walls][:subs ] += door[:gross] if type == :wall
623
+ areas[:roofs][:subs ] += door[:gross] if type == :ceiling
624
+ areas[:floors][:subs] += door[:gross] if type == :floor
625
+ bloc[:pro][:doors ] += door[:gross] * door[:u]
626
+
627
+ ok = door.key?(:ref)
628
+ bloc[:ref][:doors ] += door[:gross] * door[:ref] if ok
629
+ bloc[:ref][:doors ] += door[:gross] * door[:u ] unless ok
630
+ end
631
+ end
632
+
633
+ if surface.key?(:windows)
634
+ surface[:windows].values.each do |window|
635
+ next unless window.key?(:gross)
636
+ next unless window[:gross] > TOL
637
+ next unless window.key?(:u)
638
+ next unless window[:u] > TOL
639
+ areas[:walls][:subs ] += window[:gross] if type == :wall
640
+ areas[:roofs][:subs ] += window[:gross] if type == :ceiling
641
+ areas[:floors][:subs] += window[:gross] if type == :floor
642
+ bloc[:pro][:windows] += window[:gross] * window[:u]
643
+
644
+ ok = window.key?(:ref)
645
+ bloc[:ref][:windows ] += window[:gross] * window[:ref] if ok
646
+ bloc[:ref][:windows ] += window[:gross] * window[:u ] unless ok
647
+ end
648
+ end
649
+
650
+ if surface.key?(:skylights)
651
+ surface[:skylights].values.each do |sky|
652
+ next unless sky.key?(:gross)
653
+ next unless sky[:gross] > TOL
654
+ next unless sky.key?(:u)
655
+ next unless sky[:u] > TOL
656
+ areas[:walls][:subs ] += sky[:gross] if type == :wall
657
+ areas[:roofs][:subs ] += sky[:gross] if type == :ceiling
658
+ areas[:floors][:subs ] += sky[:gross] if type == :floor
659
+ bloc[:pro][:skylights] += sky[:gross] * sky[:u]
660
+
661
+ ok = sky.key?(:ref)
662
+ bloc[:ref][:skylights] += sky[:gross] * sky[:ref] if ok
663
+ bloc[:ref][:skylights] += sky[:gross] * sky[:u ] unless ok
664
+ end
665
+ end
666
+
667
+ if surface.key?(:edges)
668
+ surface[:edges].values.each do |edge|
669
+ next unless edge.key?(:type)
670
+ next unless edge.key?(:length)
671
+ next unless edge[:length] > TOL
672
+ next unless edge.key?(:psi)
673
+
674
+ loss = edge[:length] * edge[:psi]
675
+ type = edge[:type].to_s
676
+
677
+ case type
678
+ when /rimjoist/i
679
+ bloc[:pro][:rimjoists] += loss
680
+ when /parapet/i
681
+ bloc[:pro][:parapets ] += loss
682
+ when /fenestration/i
683
+ bloc[:pro][:trim ] += loss
684
+ when /head/i
685
+ bloc[:pro][:trim ] += loss
686
+ when /sill/i
687
+ bloc[:pro][:trim ] += loss
688
+ when /jamb/i
689
+ bloc[:pro][:trim ] += loss
690
+ when /corner/i
691
+ bloc[:pro][:corners ] += loss
692
+ when /grade/i
693
+ bloc[:pro][:grade ] += loss
694
+ else
695
+ bloc[:pro][:other ] += loss
696
+ end
697
+
698
+ next if val.empty?
699
+ next if argh[:ua_ref].empty?
700
+ safer = psi.safe(argh[:ua_ref], edge[:type])
701
+ ok = edge.key?(:ref)
702
+ loss = edge[:length] * edge[:ref] if ok
703
+ loss = edge[:length] * val[safer] * edge[:ratio] unless ok
704
+
705
+ case safer.to_s
706
+ when /rimjoist/i
707
+ bloc[:ref][:rimjoists] += loss
708
+ when /parapet/i
709
+ bloc[:ref][:parapets ] += loss
710
+ when /fenestration/i
711
+ bloc[:ref][:trim ] += loss
712
+ when /head/i
713
+ bloc[:ref][:trim ] += loss
714
+ when /sill/i
715
+ bloc[:ref][:trim ] += loss
716
+ when /jamb/i
717
+ bloc[:ref][:trim ] += loss
718
+ when /corner/i
719
+ bloc[:ref][:corners ] += loss
720
+ when /grade/i
721
+ bloc[:ref][:grade ] += loss
722
+ else
723
+ bloc[:ref][:other ] += loss
724
+ end
725
+ end
726
+ end
727
+
728
+ if surface.key?(:pts)
729
+ surface[:pts].values.each do |pts|
730
+ next unless pts.key?(:val)
731
+ next unless pts.key?(:n)
732
+ bloc[:pro][:other] += pts[:val] * pts[:n]
733
+ next unless pts.key?(:ref)
734
+ bloc[:ref][:other] += pts[:ref] * pts[:n]
735
+ end
736
+ end
737
+ end
738
+
739
+ [:en, :fr].each do |lang|
740
+ blc = [:b1, :b2]
741
+
742
+ blc.each do |b|
743
+ bloc = b1
744
+ bloc = b2 if b == :b2
745
+ pro_sum = bloc[:pro].values.reduce(:+)
746
+ ref_sum = bloc[:ref].values.reduce(:+)
747
+
748
+ if pro_sum > TOL || ref_sum > TOL
749
+ ratio = nil
750
+ ratio = (100.0 * (pro_sum - ref_sum) / ref_sum).abs if ref_sum > TOL
751
+ str = format("%.1f W/K (vs %.1f W/K)", pro_sum, ref_sum)
752
+ str += format(" +%.1f%%", ratio) if ratio && pro_sum > ref_sum # **
753
+ str += format(" -%.1f%%", ratio) if ratio && pro_sum < ref_sum
754
+ ua[lang][b] = {}
755
+
756
+ if b == :b1
757
+ ua[:en][b][:summary] = "heated : #{str}" if lang == :en
758
+ ua[:fr][b][:summary] = "chauffé : #{str}" if lang == :fr
759
+ else
760
+ ua[:en][b][:summary] = "semi-heated : #{str}" if lang == :en
761
+ ua[:fr][b][:summary] = "semi-chauffé : #{str}" if lang == :fr
762
+ end
763
+
764
+ # ** https://bugs.ruby-lang.org/issues/13761 (Ruby > 2.2.5)
765
+ # str += format(" +%.1f%", ratio) if ratio && pro_sum > ref_sum ... now:
766
+ # str += format(" +%.1f%%", ratio) if ratio && pro_sum > ref_sum
767
+
768
+ bloc[:pro].each do |k, v|
769
+ rf = bloc[:ref][k]
770
+ next if v < TOL && rf < TOL
771
+ ratio = nil
772
+ ratio = (100.0 * (v - rf) / rf).abs if rf > TOL
773
+ str = format("%.1f W/K (vs %.1f W/K)", v, rf)
774
+ str += format(" +%.1f%%", ratio) if ratio && v > rf
775
+ str += format(" -%.1f%%", ratio) if ratio && v < rf
776
+
777
+ case k
778
+ when :walls
779
+ ua[:en][b][k] = "walls : #{str}" if lang == :en
780
+ ua[:fr][b][k] = "murs : #{str}" if lang == :fr
781
+ when :roofs
782
+ ua[:en][b][k] = "roofs : #{str}" if lang == :en
783
+ ua[:fr][b][k] = "toits : #{str}" if lang == :fr
784
+ when :floors
785
+ ua[:en][b][k] = "floors : #{str}" if lang == :en
786
+ ua[:fr][b][k] = "planchers : #{str}" if lang == :fr
787
+ when :doors
788
+ ua[:en][b][k] = "doors : #{str}" if lang == :en
789
+ ua[:fr][b][k] = "portes : #{str}" if lang == :fr
790
+ when :windows
791
+ ua[:en][b][k] = "windows : #{str}" if lang == :en
792
+ ua[:fr][b][k] = "fenêtres : #{str}" if lang == :fr
793
+ when :skylights
794
+ ua[:en][b][k] = "skylights : #{str}" if lang == :en
795
+ ua[:fr][b][k] = "lanterneaux : #{str}" if lang == :fr
796
+ when :rimjoists
797
+ ua[:en][b][k] = "rimjoists : #{str}" if lang == :en
798
+ ua[:fr][b][k] = "rives : #{str}" if lang == :fr
799
+ when :parapets
800
+ ua[:en][b][k] = "parapets : #{str}" if lang == :en
801
+ ua[:fr][b][k] = "parapets : #{str}" if lang == :fr
802
+ when :trim
803
+ ua[:en][b][k] = "trim : #{str}" if lang == :en
804
+ ua[:fr][b][k] = "chassis : #{str}" if lang == :fr
805
+ when :corners
806
+ ua[:en][b][k] = "corners : #{str}" if lang == :en
807
+ ua[:fr][b][k] = "coins : #{str}" if lang == :fr
808
+ when :balconies
809
+ ua[:en][b][k] = "balconies : #{str}" if lang == :en
810
+ ua[:fr][b][k] = "balcons : #{str}" if lang == :fr
811
+ when :grade
812
+ ua[:en][b][k] = "grade : #{str}" if lang == :en
813
+ ua[:fr][b][k] = "tracé : #{str}" if lang == :fr
814
+ else
815
+ ua[:en][b][k] = "other : #{str}" if lang == :en
816
+ ua[:fr][b][k] = "autres : #{str}" if lang == :fr
817
+ end
818
+ end
819
+
820
+ # Deterministic sorting
821
+ ua[lang][b][:summary] = ua[lang][b].delete(:summary)
822
+ ok = ua[lang][b].key?(:walls)
823
+ ua[lang][b][:walls] = ua[lang][b].delete(:walls) if ok
824
+ ok = ua[lang][b].key?(:roofs)
825
+ ua[lang][b][:roofs] = ua[lang][b].delete(:roofs) if ok
826
+ ok = ua[lang][b].key?(:floors)
827
+ ua[lang][b][:floors] = ua[lang][b].delete(:floors) if ok
828
+ ok = ua[lang][b].key?(:doors)
829
+ ua[lang][b][:doors] = ua[lang][b].delete(:doors) if ok
830
+ ok = ua[lang][b].key?(:windows)
831
+ ua[lang][b][:windows] = ua[lang][b].delete(:windows) if ok
832
+ ok = ua[lang][b].key?(:skylights)
833
+ ua[lang][b][:skylights] = ua[lang][b].delete(:skylights) if ok
834
+ ok = ua[lang][b].key?(:rimjoists)
835
+ ua[lang][b][:rimjoists] = ua[lang][b].delete(:rimjoists) if ok
836
+ ok = ua[lang][b].key?(:parapets)
837
+ ua[lang][b][:parapets] = ua[lang][b].delete(:parapets) if ok
838
+ ok = ua[lang][b].key?(:trim)
839
+ ua[lang][b][:trim] = ua[lang][b].delete(:trim) if ok
840
+ ok = ua[lang][b].key?(:corners)
841
+ ua[lang][b][:corners] = ua[lang][b].delete(:corners) if ok
842
+ ok = ua[lang][b].key?(:balconies)
843
+ ua[lang][b][:balconies] = ua[lang][b].delete(:balconies) if ok
844
+ ok = ua[lang][b].key?(:grade)
845
+ ua[lang][b][:grade] = ua[lang][b].delete(:grade) if ok
846
+ ok = ua[lang][b].key?(:other)
847
+ ua[lang][b][:other] = ua[lang][b].delete(:other) if ok
848
+ end
849
+ end
850
+ end
851
+
852
+ # Areas (m2).
853
+ areas[:walls ][:gross] = areas[:walls ][:net] + areas[:walls ][:subs]
854
+ areas[:roofs ][:gross] = areas[:roofs ][:net] + areas[:roofs ][:subs]
855
+ areas[:floors][:gross] = areas[:floors][:net] + areas[:floors][:subs]
856
+ ua[:en][:areas] = {}
857
+ ua[:fr][:areas] = {}
858
+
859
+ str = format("walls : %.1f m2 (net)", areas[:walls][:net])
860
+ str += format(", %.1f m2 (gross)", areas[:walls][:gross])
861
+ ua[:en][:areas][:walls] = str unless areas[:walls][:gross] < TOL
862
+ str = format("roofs : %.1f m2 (net)", areas[:roofs][:net])
863
+ str += format(", %.1f m2 (gross)", areas[:roofs][:gross])
864
+ ua[:en][:areas][:roofs] = str unless areas[:roofs][:gross] < TOL
865
+ str = format("floors : %.1f m2 (net)", areas[:floors][:net])
866
+ str += format(", %.1f m2 (gross)", areas[:floors][:gross])
867
+ ua[:en][:areas][:floors] = str unless areas[:floors][:gross] < TOL
868
+
869
+ str = format("murs : %.1f m2 (net)", areas[:walls][:net])
870
+ str += format(", %.1f m2 (brut)", areas[:walls][:gross])
871
+ ua[:fr][:areas][:walls] = str unless areas[:walls][:gross] < TOL
872
+ str = format("toits : %.1f m2 (net)", areas[:roofs][:net])
873
+ str += format(", %.1f m2 (brut)", areas[:roofs][:gross])
874
+ ua[:fr][:areas][:roofs] = str unless areas[:roofs][:gross] < TOL
875
+ str = format("planchers : %.1f m2 (net)", areas[:floors][:net])
876
+ str += format(", %.1f m2 (brut)", areas[:floors][:gross])
877
+ ua[:fr][:areas][:floors] = str unless areas[:floors][:gross] < TOL
878
+
879
+ ua
880
+ end
881
+
882
+ ##
883
+ # Generate MD-formatted file.
884
+ #
885
+ # @param ua [Hash] preprocessed collection of UA-related strings
886
+ # @param lang [String] preferred language ("en" vs "fr")
887
+ #
888
+ # @return [Array] MD-formatted strings (empty if invalid inputs)
889
+ def ua_md(ua = {}, lang = :en)
890
+ mth = "TBD::#{__callee__}"
891
+ report = []
892
+
893
+ return mismatch("ua", ua, Hash, mth, DBG, report) unless ua.is_a?(Hash)
894
+ return empty("ua", mth, DBG, report) if ua.empty?
895
+ return hashkey("", ua, lang, mth, DBG, report) unless ua.key?(lang)
896
+
897
+ if ua[lang].key?(:objective)
898
+ report << "# #{ua[lang][:objective]} "
899
+ report << " "
900
+ end
901
+
902
+ if ua[lang].key?(:details)
903
+ ua[lang][:details].each { |d| report << "#{d} " }
904
+ report << " "
905
+ end
906
+
907
+ if ua.key?(:model)
908
+ report << "##### SUMMARY " if lang == :en
909
+ report << "##### SOMMAIRE " if lang == :fr
910
+ report << " "
911
+ report << "#{ua[:model]} "
912
+ report << " "
913
+ end
914
+
915
+ if ua[lang].key?(:b1) && ua[lang][:b1].key?(:summary)
916
+ last = ua[lang][:b1].keys.to_a.last
917
+ report << "* #{ua[lang][:b1][:summary]}"
918
+
919
+ ua[lang][:b1].each do |k, v|
920
+ next if k == :summary
921
+ report << " * #{v}" unless k == last
922
+ report << " * #{v} " if k == last
923
+ report << " " if k == last
924
+ end
925
+ report << " "
926
+ end
927
+
928
+ if ua[lang].key?(:b2) && ua[lang][:b2].key?(:summary)
929
+ last = ua[lang][:b2].keys.to_a.last
930
+ report << "* #{ua[lang][:b2][:summary]}"
931
+
932
+ ua[lang][:b2].each do |k, v|
933
+ next if k == :summary
934
+ report << " * #{v}" unless k == last
935
+ report << " * #{v} " if k == last
936
+ report << " " if k == last
937
+ end
938
+ report << " "
939
+ end
940
+
941
+ if ua.key?(:date)
942
+ report << "##### DESCRIPTION "
943
+ report << " "
944
+ report << "* project : #{ua[:descr]}" if ua.key?(:descr) && lang == :en
945
+ report << "* projet : #{ua[:descr]}" if ua.key?(:descr) && lang == :fr
946
+ model = ""
947
+ model = "* model : #{ua[:file]}" if ua.key?(:file) && lang == :en
948
+ model = "* modèle : #{ua[:file]}" if ua.key?(:file) && lang == :fr
949
+ model += " (v#{ua[:version]})" if ua.key?(:version)
950
+ report << model unless model.empty?
951
+ report << "* TBD : v3.0.0"
952
+ report << "* date : #{ua[:date]}"
953
+
954
+ if lang == :en
955
+ report << "* status : #{msg(status)}" unless status.zero?
956
+ report << "* status : success !" if status.zero?
957
+ elsif lang == :fr
958
+ report << "* statut : #{msg(status)}" unless status.zero?
959
+ report << "* statut : succès !" if status.zero?
960
+ end
961
+ report << " "
962
+ end
963
+
964
+ if ua[lang].key?(:areas)
965
+ report << "##### AREAS " if lang == :en
966
+ report << "##### AIRES " if lang == :fr
967
+ report << " "
968
+ ok = ua[lang][:areas].key?(:walls)
969
+ report << "* #{ua[lang][:areas][:walls]}" if ok
970
+ ok = ua[lang][:areas].key?(:roofs)
971
+ report << "* #{ua[lang][:areas][:roofs]}" if ok
972
+ ok = ua[lang][:areas].key?(:floors)
973
+ report << "* #{ua[lang][:areas][:floors]}" if ok
974
+ report << " "
975
+ end
976
+
977
+ if ua[lang].key?(:notes)
978
+ report << "##### NOTES "
979
+ report << " "
980
+ report << "#{ua[lang][:notes]} "
981
+ report << " "
982
+ end
983
+
984
+ report
985
+ end
986
+ end