tbd 3.2.3 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -31,23 +31,1094 @@
31
31
  require "openstudio"
32
32
 
33
33
  module OSut
34
- extend OSlg # DEBUG for devs; WARN/ERROR for users (bad OS input)
35
-
36
- TOL = 0.01
37
- TOL2 = TOL * TOL
38
- DBG = OSut::DEBUG # mainly to flag invalid arguments to devs (buggy code)
39
- INF = OSut::INFO # not currently used in OSut
40
- WRN = OSut::WARN # WARN users of 'iffy' .osm inputs (yet not critical)
41
- ERR = OSut::ERROR # flag invalid .osm inputs (then exit via 'return')
42
- FTL = OSut::FATAL # not currently used in OSut
43
- NS = "nameString" # OpenStudio IdfObject nameString method
44
- HEAD = 2.032 # standard 80" door
45
- SILL = 0.762 # standard 30" window sill
46
-
47
- # This first set of utilities (~750 lines) help distinguishing spaces that
48
- # are directly vs indirectly CONDITIONED, vs SEMI-HEATED. The solution here
34
+ # DEBUG for devs; WARN/ERROR for users (bad OS input), see OSlg
35
+ extend OSlg
36
+
37
+ TOL = 0.01 # default distance tolerance (m)
38
+ TOL2 = TOL * TOL # default area tolerance (m2)
39
+ DBG = OSlg::DEBUG # see github.com/rd2/oslg
40
+ INF = OSlg::INFO # see github.com/rd2/oslg
41
+ WRN = OSlg::WARN # see github.com/rd2/oslg
42
+ ERR = OSlg::ERROR # see github.com/rd2/oslg
43
+ FTL = OSlg::FATAL # see github.com/rd2/oslg
44
+ NS = "nameString" # OpenStudio object identifier method
45
+
46
+ HEAD = 2.032 # standard 80" door
47
+ SILL = 0.762 # standard 30" window sill
48
+
49
+ # General surface orientations (see facets method)
50
+ SIDZ = [:bottom, # e.g. ground-facing, exposed floros
51
+ :top, # e.g. roof/ceiling
52
+ :north, # NORTH
53
+ :east, # EAST
54
+ :south, # SOUTH
55
+ :west # WEST
56
+ ].freeze
57
+
58
+ # This first set of utilities support OpenStudio materials, constructions,
59
+ # construction sets, etc. If relying on default StandardOpaqueMaterial:
60
+ # - roughness (rgh) : "Smooth"
61
+ # - thickness : 0.1 m
62
+ # - thermal conductivity (k ) : 0.1 W/m.K
63
+ # - density (rho) : 0.1 kg/m3
64
+ # - specific heat (cp ) : 1400.0 J/kg.K
65
+ #
66
+ # https://s3.amazonaws.com/openstudio-sdk-documentation/cpp/
67
+ # OpenStudio-3.6.1-doc/model/html/
68
+ # classopenstudio_1_1model_1_1_standard_opaque_material.html
69
+ #
70
+ # ... apart from surface roughness, rarely would these material properties be
71
+ # suitable - and are therefore explicitely set below. On roughness:
72
+ # - "Very Rough" : stucco
73
+ # - "Rough" : brick
74
+ # - "Medium Rough" : concrete
75
+ # - "Medium Smooth" : clear pine
76
+ # - "Smooth" : smooth plaster
77
+ # - "Very Smooth" : glass
78
+
79
+ # thermal mass categories (e.g. exterior cladding, interior finish, framing)
80
+ @@mass = [
81
+ :none, # token for 'no user selection', resort to defaults
82
+ :light, # e.g. 16mm drywall interior
83
+ :medium, # e.g. 100mm brick cladding
84
+ :heavy # e.g. 200mm poured concrete
85
+ ].freeze
86
+
87
+ # basic materials (StandardOpaqueMaterials only)
88
+ @@mats = {
89
+ sand: {},
90
+ concrete: {},
91
+ brick: {},
92
+ cladding: {}, # e.g. lightweight cladding over furring
93
+ sheathing: {}, # e.g. plywood
94
+ polyiso: {}, # e.g. polyisocyanurate panel (or similar)
95
+ cellulose: {}, # e.g. blown, dry/stabilized fiber
96
+ mineral: {}, # e.g. semi-rigid rock wool insulation
97
+ drywall: {},
98
+ door: {} # single composite material (45mm insulated steel door)
99
+ }.freeze
100
+
101
+ # default inside+outside air film resistances (m2.K/W)
102
+ @@film = {
103
+ shading: 0.000, # NA
104
+ partition: 0.000,
105
+ wall: 0.150,
106
+ roof: 0.140,
107
+ floor: 0.190,
108
+ basement: 0.120,
109
+ slab: 0.160,
110
+ door: 0.150,
111
+ window: 0.150, # ignored if SimpleGlazingMaterial
112
+ skylight: 0.140 # ignored if SimpleGlazingMaterial
113
+ }.freeze
114
+
115
+ # default (~1980s) envelope Uo (W/m2•K), based on surface type
116
+ @@uo = {
117
+ shading: 0.000, # N/A
118
+ partition: 0.000, # N/A
119
+ wall: 0.384, # rated Ro ~14.8 hr•ft2F/Btu
120
+ roof: 0.327, # rated Ro ~17.6 hr•ft2F/Btu
121
+ floor: 0.317, # rated Ro ~17.9 hr•ft2F/Btu (exposed floor)
122
+ basement: 0.000, # uninsulated
123
+ slab: 0.000, # uninsulated
124
+ door: 1.800, # insulated, unglazed steel door (single layer)
125
+ window: 2.800, # e.g. patio doors (simple glazing)
126
+ skylight: 3.500 # all skylight technologies
127
+ }.freeze
128
+
129
+ # Standard opaque materials, taken from a variety of sources (e.g. energy
130
+ # codes, NREL's BCL). Material identifiers are symbols, e.g.:
131
+ # - :brick
132
+ # - :sand
133
+ # - :concrete
134
+ #
135
+ # Material properties remain largely constant between projects. What does
136
+ # tend to vary (between projects) are thicknesses. Actual OpenStudio opaque
137
+ # material objects can be (re)set in more than one way by class methods.
138
+ # In genConstruction, OpenStudio object identifiers are later suffixed with
139
+ # actual material thicknesses, in mm, e.g.:
140
+ # - "concrete200" : 200mm concrete slab
141
+ # - "drywall13" : 1/2" gypsum board
142
+ # - "drywall16" : 5/8" gypsum board
143
+ #
144
+ # Surface absorptances are also defaulted in OpenStudio:
145
+ # - thermal, long-wave (thm) : 90%
146
+ # - solar (sol) : 70%
147
+ # - visible (vis) : 70%
148
+ #
149
+ # These can also be explicitly set, here (e.g. a redundant 'sand' example):
150
+ @@mats[:sand ][:rgh] = "Rough"
151
+ @@mats[:sand ][:k ] = 1.290
152
+ @@mats[:sand ][:rho] = 2240.000
153
+ @@mats[:sand ][:cp ] = 830.000
154
+ @@mats[:sand ][:thm] = 0.900
155
+ @@mats[:sand ][:sol] = 0.700
156
+ @@mats[:sand ][:vis] = 0.700
157
+
158
+ @@mats[:concrete ][:rgh] = "MediumRough"
159
+ @@mats[:concrete ][:k ] = 1.730
160
+ @@mats[:concrete ][:rho] = 2240.000
161
+ @@mats[:concrete ][:cp ] = 830.000
162
+
163
+ @@mats[:brick ][:rgh] = "Rough"
164
+ @@mats[:brick ][:k ] = 0.675
165
+ @@mats[:brick ][:rho] = 1600.000
166
+ @@mats[:brick ][:cp ] = 790.000
167
+
168
+ @@mats[:cladding ][:rgh] = "MediumSmooth"
169
+ @@mats[:cladding ][:k ] = 0.115
170
+ @@mats[:cladding ][:rho] = 540.000
171
+ @@mats[:cladding ][:cp ] = 1200.000
172
+
173
+ @@mats[:sheathing][:k ] = 0.160
174
+ @@mats[:sheathing][:rho] = 545.000
175
+ @@mats[:sheathing][:cp ] = 1210.000
176
+
177
+ @@mats[:polyiso ][:k ] = 0.025
178
+ @@mats[:polyiso ][:rho] = 25.000
179
+ @@mats[:polyiso ][:cp ] = 1590.000
180
+
181
+ @@mats[:cellulose][:rgh] = "VeryRough"
182
+ @@mats[:cellulose][:k ] = 0.050
183
+ @@mats[:cellulose][:rho] = 80.000
184
+ @@mats[:cellulose][:cp ] = 835.000
185
+
186
+ @@mats[:mineral ][:k ] = 0.050
187
+ @@mats[:mineral ][:rho] = 19.000
188
+ @@mats[:mineral ][:cp ] = 960.000
189
+
190
+ @@mats[:drywall ][:k ] = 0.160
191
+ @@mats[:drywall ][:rho] = 785.000
192
+ @@mats[:drywall ][:cp ] = 1090.000
193
+
194
+ @@mats[:door ][:rgh] = "MediumSmooth"
195
+ @@mats[:door ][:k ] = 0.080
196
+ @@mats[:door ][:rho] = 600.000
197
+ @@mats[:door ][:cp ] = 1000.000
198
+
199
+ ##
200
+ # Generates an OpenStudio multilayered construction; materials if needed.
201
+ #
202
+ # @param model [OpenStudio::Model::Model] a model
203
+ # @param [Hash] specs OpenStudio construction specifications
204
+ # @option specs [#to_s] :id ("") construction identifier
205
+ # @option specs [Symbol] :type (:wall), see @@uo
206
+ # @option specs [Numeric] :uo clear-field Uo, in W/m2.K, see @@uo
207
+ # @option specs [Symbol] :clad (:light) exterior cladding, see @@mass
208
+ # @option specs [Symbol] :frame (:light) assembly framing, see @@mass
209
+ # @option specs [Symbol] :finish (:light) interior finishing, see @@mass
210
+ #
211
+ # @return [OpenStudio::Model::Construction] generated construction
212
+ # @return [nil] if invalid inputs (see logs)
213
+ def genConstruction(model = nil, specs = {})
214
+ mth = "OSut::#{__callee__}"
215
+ cl1 = OpenStudio::Model::Model
216
+ cl2 = Hash
217
+ return mismatch("model", model, cl1, mth) unless model.is_a?(cl1)
218
+ return mismatch("specs", specs, cl2, mth) unless specs.is_a?(cl2)
219
+
220
+ specs[:id ] = "" unless specs.key?(:id )
221
+ specs[:type] = :wall unless specs.key?(:type)
222
+ chk = @@uo.keys.include?(specs[:type])
223
+ return invalid("surface type", mth, 2, ERR) unless chk
224
+
225
+ id = trim(specs[:id])
226
+ id = "OSut|CON|#{specs[:type]}" if id.empty?
227
+ specs[:uo] = @@uo[ specs[:type] ] unless specs.key?(:uo)
228
+ u = specs[:uo]
229
+ return mismatch("#{id} Uo", u, Numeric, mth) unless u.is_a?(Numeric)
230
+ return invalid("#{id} Uo (> 5.678)", mth, 2, ERR) if u > 5.678
231
+ return negative("#{id} Uo" , mth, ERR) if u < 0
232
+
233
+ # Optional specs. Log/reset if invalid.
234
+ specs[:clad ] = :light unless specs.key?(:clad ) # exterior
235
+ specs[:frame ] = :light unless specs.key?(:frame )
236
+ specs[:finish] = :light unless specs.key?(:finish) # interior
237
+ log(WRN, "Reset to light cladding") unless @@mass.include?(specs[:clad ])
238
+ log(WRN, "Reset to light framing" ) unless @@mass.include?(specs[:frame ])
239
+ log(WRN, "Reset to light finish" ) unless @@mass.include?(specs[:finish])
240
+ specs[:clad ] = :light unless @@mass.include?(specs[:clad ])
241
+ specs[:frame ] = :light unless @@mass.include?(specs[:frame ])
242
+ specs[:finish] = :light unless @@mass.include?(specs[:finish])
243
+
244
+ film = @@film[ specs[:type] ]
245
+
246
+ # Layered assembly (max 4 layers):
247
+ # - cladding
248
+ # - intermediate sheathing
249
+ # - composite insulating/framing
250
+ # - interior finish
251
+ a = {clad: {}, sheath: {}, compo: {}, finish: {}, glazing: {}}
252
+
253
+ case specs[:type]
254
+ when :shading
255
+ mt = :sheathing
256
+ d = 0.015
257
+ a[:compo][:mat] = @@mats[mt]
258
+ a[:compo][:d ] = d
259
+ a[:compo][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}"
260
+ when :partition
261
+ d = 0.015
262
+ mt = :drywall
263
+ a[:clad][:mat] = @@mats[mt]
264
+ a[:clad][:d ] = d
265
+ a[:clad][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}"
266
+
267
+ mt = :sheathing
268
+ a[:compo][:mat] = @@mats[mt]
269
+ a[:compo][:d ] = d
270
+ a[:compo][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}"
271
+
272
+ mt = :drywall
273
+ a[:finish][:mat] = @@mats[mt]
274
+ a[:finish][:d ] = d
275
+ a[:finish][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}"
276
+ when :wall
277
+ unless specs[:clad] == :none
278
+ mt = :cladding
279
+ mt = :brick if specs[:clad] == :medium
280
+ mt = :concrete if specs[:clad] == :heavy
281
+ d = 0.100
282
+ d = 0.015 if specs[:clad] == :light
283
+ a[:clad][:mat] = @@mats[mt]
284
+ a[:clad][:d ] = d
285
+ a[:clad][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}"
286
+ end
287
+
288
+ mt = :drywall
289
+ mt = :polyiso if specs[:frame] == :medium
290
+ mt = :mineral if specs[:frame] == :heavy
291
+ d = 0.100
292
+ d = 0.015 if specs[:frame] == :light
293
+ a[:sheath][:mat] = @@mats[mt]
294
+ a[:sheath][:d ] = d
295
+ a[:sheath][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}"
296
+
297
+ mt = :concrete
298
+ mt = :mineral if specs[:frame] == :light
299
+ d = 0.100
300
+ d = 0.200 if specs[:frame] == :heavy
301
+ a[:compo][:mat] = @@mats[mt]
302
+ a[:compo][:d ] = d
303
+ a[:compo][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}"
304
+
305
+ unless specs[:finish] == :none
306
+ mt = :concrete
307
+ mt = :drywall if specs[:finish] == :light
308
+ d = 0.015
309
+ d = 0.100 if specs[:finish] == :medium
310
+ d = 0.200 if specs[:finish] == :heavy
311
+ a[:finish][:mat] = @@mats[mt]
312
+ a[:finish][:d ] = d
313
+ a[:finish][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}"
314
+ end
315
+ when :roof
316
+ unless specs[:clad] == :none
317
+ mt = :concrete
318
+ mt = :cladding if specs[:clad] == :light
319
+ d = 0.015
320
+ d = 0.100 if specs[:clad] == :medium # e.g. terrace
321
+ d = 0.200 if specs[:clad] == :heavy # e.g. parking garage
322
+ a[:clad][:mat] = @@mats[mt]
323
+ a[:clad][:d ] = d
324
+ a[:clad][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}"
325
+
326
+ mt = :sheathing
327
+ d = 0.015
328
+ a[:sheath][:mat] = @@mats[mt]
329
+ a[:sheath][:d ] = d
330
+ a[:sheath][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}"
331
+ end
332
+
333
+ mt = :cellulose
334
+ mt = :polyiso if specs[:frame] == :medium
335
+ mt = :mineral if specs[:frame] == :heavy
336
+ d = 0.100
337
+ a[:compo][:mat] = @@mats[mt]
338
+ a[:compo][:d ] = d
339
+ a[:compo][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}"
340
+
341
+ unless specs[:finish] == :none
342
+ mt = :concrete
343
+ mt = :drywall if specs[:finish] == :light
344
+ d = 0.015
345
+ d = 0.100 if specs[:finish] == :medium # proxy for steel decking
346
+ d = 0.200 if specs[:finish] == :heavy
347
+ a[:finish][:mat] = @@mats[mt]
348
+ a[:finish][:d ] = d
349
+ a[:finish][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}"
350
+ end
351
+ when :floor # exposed
352
+ unless specs[:clad] == :none
353
+ mt = :cladding
354
+ d = 0.015
355
+ a[:clad][:mat] = @@mats[mt]
356
+ a[:clad][:d ] = d
357
+ a[:clad][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}"
358
+
359
+ mt = :sheathing
360
+ d = 0.015
361
+ a[:sheath][:mat] = @@mats[mt]
362
+ a[:sheath][:d ] = d
363
+ a[:sheath][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}"
364
+ end
365
+
366
+ mt = :cellulose
367
+ mt = :polyiso if specs[:frame] == :medium
368
+ mt = :mineral if specs[:frame] == :heavy
369
+ d = 0.100 # possibly an insulating layer to reset
370
+ a[:compo][:mat] = @@mats[mt]
371
+ a[:compo][:d ] = d
372
+ a[:compo][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}"
373
+
374
+ unless specs[:finish] == :none
375
+ mt = :concrete
376
+ mt = :sheathing if specs[:finish] == :light
377
+ d = 0.015
378
+ d = 0.100 if specs[:finish] == :medium
379
+ d = 0.200 if specs[:finish] == :heavy
380
+ a[:finish][:mat] = @@mats[mt]
381
+ a[:finish][:d ] = d
382
+ a[:finish][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}"
383
+ end
384
+ when :slab # basement slab or slab-on-grade
385
+ mt = :sand
386
+ d = 0.100
387
+ a[:clad][:mat] = @@mats[mt]
388
+ a[:clad][:d ] = d
389
+ a[:clad][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}"
390
+
391
+ unless specs[:frame] == :none
392
+ mt = :polyiso
393
+ d = 0.025
394
+ a[:sheath][:mat] = @@mats[mt]
395
+ a[:sheath][:d ] = d
396
+ a[:sheath][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}"
397
+ end
398
+
399
+ mt = :concrete
400
+ d = 0.100
401
+ d = 0.200 if specs[:frame] == :heavy
402
+ a[:compo][:mat] = @@mats[mt]
403
+ a[:compo][:d ] = d
404
+ a[:compo][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}"
405
+
406
+ unless specs[:finish] == :none
407
+ mt = :sheathing
408
+ d = 0.015
409
+ a[:finish][:mat] = @@mats[mt]
410
+ a[:finish][:d ] = d
411
+ a[:finish][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}"
412
+ end
413
+ when :basement # wall
414
+ unless specs[:clad] == :none
415
+ mt = :concrete
416
+ mt = :sheathing if specs[:clad] == :light
417
+ d = 0.100
418
+ d = 0.015 if specs[:clad] == :light
419
+ a[:clad][:mat] = @@mats[mt]
420
+ a[:clad][:d ] = d
421
+ a[:clad][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}"
422
+
423
+ mt = :polyiso
424
+ d = 0.025
425
+ a[:sheath][:mat] = @@mats[mt]
426
+ a[:sheath][:d ] = d
427
+ a[:sheath][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}"
428
+
429
+ mt = :concrete
430
+ d = 0.200
431
+ a[:compo][:mat] = @@mats[mt]
432
+ a[:compo][:d ] = d
433
+ a[:compo][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}"
434
+ else
435
+ mt = :concrete
436
+ d = 0.200
437
+ a[:sheath][:mat] = @@mats[mt]
438
+ a[:sheath][:d ] = d
439
+ a[:sheath][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}"
440
+
441
+ unless specs[:finish] == :none
442
+ mt = :mineral
443
+ d = 0.075
444
+ a[:compo][:mat] = @@mats[mt]
445
+ a[:compo][:d ] = d
446
+ a[:compo][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}"
447
+
448
+ mt = :drywall
449
+ d = 0.015
450
+ a[:finish][:mat] = @@mats[mt]
451
+ a[:finish][:d ] = d
452
+ a[:finish][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}"
453
+ end
454
+ end
455
+ when :door # opaque
456
+ # 45mm insulated (composite) steel door.
457
+ mt = :door
458
+ d = 0.045
459
+
460
+ a[:compo ][:mat ] = @@mats[mt]
461
+ a[:compo ][:d ] = d
462
+ a[:compo ][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}"
463
+ when :window # e.g. patio doors (simple glazing)
464
+ # SimpleGlazingMaterial.
465
+ a[:glazing][:u ] = specs[:uo ]
466
+ a[:glazing][:shgc] = 0.450
467
+ a[:glazing][:shgc] = specs[:shgc] if specs.key?(:shgc)
468
+ a[:glazing][:id ] = "OSut|window"
469
+ a[:glazing][:id ] += "|U#{format('%.1f', a[:glazing][:u])}"
470
+ a[:glazing][:id ] += "|SHGC#{format('%d', a[:glazing][:shgc]*100)}"
471
+ when :skylight
472
+ # SimpleGlazingMaterial.
473
+ a[:glazing][:u ] = specs[:uo ]
474
+ a[:glazing][:shgc] = 0.450
475
+ a[:glazing][:shgc] = specs[:shgc] if specs.key?(:shgc)
476
+ a[:glazing][:id ] = "OSut|skylight"
477
+ a[:glazing][:id ] += "|U#{format('%.1f', a[:glazing][:u])}"
478
+ a[:glazing][:id ] += "|SHGC#{format('%d', a[:glazing][:shgc]*100)}"
479
+ end
480
+
481
+ # Initiate layers.
482
+ glazed = true
483
+ glazed = false if a[:glazing].empty?
484
+ layers = OpenStudio::Model::OpaqueMaterialVector.new unless glazed
485
+ layers = OpenStudio::Model::FenestrationMaterialVector.new if glazed
486
+
487
+ if glazed
488
+ u = a[:glazing][:u ]
489
+ shgc = a[:glazing][:shgc]
490
+ lyr = model.getSimpleGlazingByName(a[:glazing][:id])
491
+
492
+ if lyr.empty?
493
+ lyr = OpenStudio::Model::SimpleGlazing.new(model, u, shgc)
494
+ lyr.setName(a[:glazing][:id])
495
+ else
496
+ lyr = lyr.get
497
+ end
498
+
499
+ layers << lyr
500
+ else
501
+ # Loop through each layer spec, and generate construction.
502
+ a.each do |i, l|
503
+ next if l.empty?
504
+
505
+ lyr = model.getStandardOpaqueMaterialByName(l[:id])
506
+
507
+ if lyr.empty?
508
+ lyr = OpenStudio::Model::StandardOpaqueMaterial.new(model)
509
+ lyr.setName(l[:id])
510
+ lyr.setThickness(l[:d])
511
+ lyr.setRoughness( l[:mat][:rgh]) if l[:mat].key?(:rgh)
512
+ lyr.setConductivity( l[:mat][:k ]) if l[:mat].key?(:k )
513
+ lyr.setDensity( l[:mat][:rho]) if l[:mat].key?(:rho)
514
+ lyr.setSpecificHeat( l[:mat][:cp ]) if l[:mat].key?(:cp )
515
+ lyr.setThermalAbsorptance(l[:mat][:thm]) if l[:mat].key?(:thm)
516
+ lyr.setSolarAbsorptance( l[:mat][:sol]) if l[:mat].key?(:sol)
517
+ lyr.setVisibleAbsorptance(l[:mat][:vis]) if l[:mat].key?(:vis)
518
+ else
519
+ lyr = lyr.get
520
+ end
521
+
522
+ layers << lyr
523
+ end
524
+ end
525
+
526
+ c = OpenStudio::Model::Construction.new(layers)
527
+ c.setName(id)
528
+
529
+ # Adjust insulating layer thickness or conductivity to match requested Uo.
530
+ unless glazed
531
+ ro = 0
532
+ ro = 1 / specs[:uo] - @@film[ specs[:type] ] if specs[:uo] > 0
533
+
534
+ if specs[:type] == :door # 1x layer, adjust conductivity
535
+ layer = c.getLayer(0).to_StandardOpaqueMaterial
536
+ return invalid("#{id} standard material?", mth, 0) if layer.empty?
537
+
538
+ layer = layer.get
539
+ k = layer.thickness / ro
540
+ layer.setConductivity(k)
541
+ elsif ro > 0 # multiple layers, adjust insulating layer thickness
542
+ lyr = insulatingLayer(c)
543
+ return invalid("#{id} construction", mth, 0) if lyr[:index].nil?
544
+ return invalid("#{id} construction", mth, 0) if lyr[:type ].nil?
545
+ return invalid("#{id} construction", mth, 0) if lyr[:r ].zero?
546
+
547
+ index = lyr[:index]
548
+ layer = c.getLayer(index).to_StandardOpaqueMaterial
549
+ return invalid("#{id} material @#{index}", mth, 0) if layer.empty?
550
+
551
+ layer = layer.get
552
+ k = layer.conductivity
553
+ d = (ro - rsi(c) + lyr[:r]) * k
554
+ return invalid("#{id} adjusted m", mth, 0) if d < 0.03
555
+
556
+ nom = "OSut|"
557
+ nom += layer.nameString.gsub(/[^a-z]/i, "").gsub("OSut", "")
558
+ nom += "|"
559
+ nom += format("%03d", d*1000)[-3..-1]
560
+ layer.setName(nom) if model.getStandardOpaqueMaterialByName(nom).empty?
561
+ layer.setThickness(d)
562
+ end
563
+ end
564
+
565
+ c
566
+ end
567
+
568
+ ##
569
+ # Generates a solar shade (e.g. roller, textile) for glazed OpenStudio
570
+ # SubSurfaces (v351+), controlled to minimize overheating in cooling months
571
+ # (May to October in Northern Hemisphere), when outdoor dry bulb temperature
572
+ # is above 18°C and impinging solar radiation is above 100 W/m2.
573
+ #
574
+ # @param subs [OpenStudio::Model::SubSurfaceVector] sub surfaces
575
+ #
576
+ # @return [Bool] whether successfully generated
577
+ # @return [false] if invalid input (see logs)
578
+ def genShade(subs = OpenStudio::Model::SubSurfaceVector.new)
579
+ # Filter OpenStudio warnings for ShadingControl:
580
+ # ref: https://github.com/NREL/OpenStudio/issues/4911
581
+ str = ".*(?<!ShadingControl)$"
582
+ OpenStudio::Logger.instance.standardOutLogger.setChannelRegex(str)
583
+
584
+ mth = "OSut::#{__callee__}"
585
+ v = OpenStudio.openStudioVersion.split(".").join.to_i
586
+ cl = OpenStudio::Model::SubSurfaceVector
587
+ return mismatch("subs ", subs, cl2, mth, DBG, false) unless subs.is_a?(cl)
588
+ return empty( "subs", mth, WRN, false) if subs.empty?
589
+ return false if v < 321
590
+
591
+ # Shading availability period.
592
+ mdl = subs.first.model
593
+ id = "onoff"
594
+ onoff = mdl.getScheduleTypeLimitsByName(id)
595
+
596
+ if onoff.empty?
597
+ onoff = OpenStudio::Model::ScheduleTypeLimits.new(mdl)
598
+ onoff.setName(id)
599
+ onoff.setLowerLimitValue(0)
600
+ onoff.setUpperLimitValue(1)
601
+ onoff.setNumericType("Discrete")
602
+ onoff.setUnitType("Availability")
603
+ else
604
+ onoff = onoff.get
605
+ end
606
+
607
+ # Shading schedule.
608
+ id = "OSut|SHADE|Ruleset"
609
+ sch = mdl.getScheduleRulesetByName(id)
610
+
611
+ if sch.empty?
612
+ sch = OpenStudio::Model::ScheduleRuleset.new(mdl, 0)
613
+ sch.setName(id)
614
+ sch.setScheduleTypeLimits(onoff)
615
+ sch.defaultDaySchedule.setName("OSut|Shade|Ruleset|Default")
616
+ else
617
+ sch = sch.get
618
+ end
619
+
620
+ # Summer cooling rule.
621
+ id = "OSut|SHADE|ScheduleRule"
622
+ rule = mdl.getScheduleRuleByName(id)
623
+
624
+ if rule.empty?
625
+ may = OpenStudio::MonthOfYear.new("May")
626
+ october = OpenStudio::MonthOfYear.new("Oct")
627
+ start = OpenStudio::Date.new(may, 1)
628
+ finish = OpenStudio::Date.new(october, 31)
629
+
630
+ rule = OpenStudio::Model::ScheduleRule.new(sch)
631
+ rule.setName(id)
632
+ rule.setStartDate(start)
633
+ rule.setEndDate(finish)
634
+ rule.setApplyAllDays(true)
635
+ rule.daySchedule.setName("OSut|Shade|Rule|Default")
636
+ rule.daySchedule.addValue(OpenStudio::Time.new(0,24,0,0), 1)
637
+ else
638
+ rule = rule.get
639
+ end
640
+
641
+ # Shade object.
642
+ id = "OSut|Shade"
643
+ shd = mdl.getShadeByName(id)
644
+
645
+ if shd.empty?
646
+ shd = OpenStudio::Model::Shade.new(mdl)
647
+ shd.setName(id)
648
+ else
649
+ shd = shd.get
650
+ end
651
+
652
+ # Shading control (unique to each call).
653
+ id = "OSut|ShadingControl"
654
+ ctl = OpenStudio::Model::ShadingControl.new(shd)
655
+ ctl.setName(id)
656
+ ctl.setSchedule(sch)
657
+ ctl.setShadingControlType("OnIfHighOutdoorAirTempAndHighSolarOnWindow")
658
+ ctl.setSetpoint(18) # °C
659
+ ctl.setSetpoint2(100) # W/m2
660
+ ctl.setMultipleSurfaceControlType("Group")
661
+ ctl.setSubSurfaces(subs)
662
+ end
663
+
664
+ ##
665
+ # Generates an internal mass definition and instances for target spaces.
666
+ #
667
+ # @param sps [OpenStudio::Model::SpaceVector] target spaces
668
+ # @param ratio [Numeric] internal mass surface / floor areas
669
+ #
670
+ # @return [Bool] whether successfully generated
671
+ # @return [false] if invalid input (see logs)
672
+ def genMass(sps = OpenStudio::Model::SpaceVector.new, ratio = 2.0)
673
+ # This is largely adapted from OpenStudio-Standards:
674
+ #
675
+ # https://github.com/NREL/openstudio-standards/blob/
676
+ # d332605c2f7a35039bf658bf55cad40a7bcac317/lib/openstudio-standards/
677
+ # prototypes/common/objects/Prototype.Model.rb#L786
678
+ mth = "OSut::#{__callee__}"
679
+ cl1 = OpenStudio::Model::SpaceVector
680
+ cl2 = Numeric
681
+ no = false
682
+ return mismatch("spaces", sps, cl1, mth, DBG, no) unless sps.is_a?(cl1)
683
+ return mismatch( "ratio", ratio, cl2, mth, DBG, no) unless ratio.is_a?(cl2)
684
+ return empty( "spaces", mth, WRN, no) if sps.empty?
685
+ return negative( "ratio", mth, ERR, no) if ratio < 0
686
+
687
+ # A single material.
688
+ mdl = sps.first.model
689
+ id = "OSut|MASS|Material"
690
+ mat = mdl.getOpaqueMaterialByName(id)
691
+
692
+ if mat.empty?
693
+ mat = OpenStudio::Model::StandardOpaqueMaterial.new(mdl)
694
+ mat.setName(id)
695
+ mat.setRoughness("MediumRough")
696
+ mat.setThickness(0.15)
697
+ mat.setConductivity(1.12)
698
+ mat.setDensity(540)
699
+ mat.setSpecificHeat(1210)
700
+ mat.setThermalAbsorptance(0.9)
701
+ mat.setSolarAbsorptance(0.7)
702
+ mat.setVisibleAbsorptance(0.17)
703
+ else
704
+ mat = mat.get
705
+ end
706
+
707
+ # A single, 1x layered construction.
708
+ id = "OSut|MASS|Construction"
709
+ con = mdl.getConstructionByName(id)
710
+
711
+ if con.empty?
712
+ con = OpenStudio::Model::Construction.new(mdl)
713
+ con.setName(id)
714
+ layers = OpenStudio::Model::MaterialVector.new
715
+ layers << mat
716
+ con.setLayers(layers)
717
+ else
718
+ con = con.get
719
+ end
720
+
721
+ id = "OSut|InternalMassDefinition|" + (format "%.2f", ratio)
722
+ df = mdl.getInternalMassDefinitionByName(id)
723
+
724
+ if df.empty?
725
+ df = OpenStudio::Model::InternalMassDefinition.new(mdl)
726
+ df.setName(id)
727
+ df.setConstruction(con)
728
+ df.setSurfaceAreaperSpaceFloorArea(ratio)
729
+ else
730
+ df = df.get
731
+ end
732
+
733
+ sps.each do |sp|
734
+ mass = OpenStudio::Model::InternalMass.new(df)
735
+ mass.setName("OSut|InternalMass|#{sp.nameString}")
736
+ mass.setSpace(sp)
737
+ end
738
+
739
+ true
740
+ end
741
+
742
+ ##
743
+ # Validates if a default construction set holds a base construction.
744
+ #
745
+ # @param set [OpenStudio::Model::DefaultConstructionSet] a default set
746
+ # @param bse [OpensStudio::Model::ConstructionBase] a construction base
747
+ # @param gr [Bool] if ground-facing surface
748
+ # @param ex [Bool] if exterior-facing surface
749
+ # @param tp [#to_s] a surface type
750
+ #
751
+ # @return [Bool] whether default set holds construction
752
+ # @return [false] if invalid input (see logs)
753
+ def holdsConstruction?(set = nil, bse = nil, gr = false, ex = false, tp = "")
754
+ mth = "OSut::#{__callee__}"
755
+ cl1 = OpenStudio::Model::DefaultConstructionSet
756
+ cl2 = OpenStudio::Model::ConstructionBase
757
+ ck1 = set.respond_to?(NS)
758
+ ck2 = bse.respond_to?(NS)
759
+ return invalid("set" , mth, 1, DBG, false) unless ck1
760
+ return invalid("base", mth, 2, DBG, false) unless ck2
761
+
762
+ id1 = set.nameString
763
+ id2 = bse.nameString
764
+ ck1 = set.is_a?(cl1)
765
+ ck2 = bse.is_a?(cl2)
766
+ ck3 = [true, false].include?(gr)
767
+ ck4 = [true, false].include?(ex)
768
+ ck5 = tp.respond_to?(:to_s)
769
+ return mismatch(id1, set, cl1, mth, DBG, false) unless ck1
770
+ return mismatch(id2, bse, cl2, mth, DBG, false) unless ck2
771
+ return invalid("ground" , mth, 3, DBG, false) unless ck3
772
+ return invalid("exterior" , mth, 4, DBG, false) unless ck4
773
+ return invalid("surface type", mth, 5, DBG, false) unless ck5
774
+
775
+ type = trim(tp).downcase
776
+ ck1 = ["floor", "wall", "roofceiling"].include?(type)
777
+ return invalid("surface type", mth, 5, DBG, false) unless ck1
778
+
779
+ constructions = nil
780
+
781
+ if gr
782
+ unless set.defaultGroundContactSurfaceConstructions.empty?
783
+ constructions = set.defaultGroundContactSurfaceConstructions.get
784
+ end
785
+ elsif ex
786
+ unless set.defaultExteriorSurfaceConstructions.empty?
787
+ constructions = set.defaultExteriorSurfaceConstructions.get
788
+ end
789
+ else
790
+ unless set.defaultInteriorSurfaceConstructions.empty?
791
+ constructions = set.defaultInteriorSurfaceConstructions.get
792
+ end
793
+ end
794
+
795
+ return false unless constructions
796
+
797
+ case type
798
+ when "roofceiling"
799
+ unless constructions.roofCeilingConstruction.empty?
800
+ construction = constructions.roofCeilingConstruction.get
801
+ return true if construction == bse
802
+ end
803
+ when "floor"
804
+ unless constructions.floorConstruction.empty?
805
+ construction = constructions.floorConstruction.get
806
+ return true if construction == bse
807
+ end
808
+ else
809
+ unless constructions.wallConstruction.empty?
810
+ construction = constructions.wallConstruction.get
811
+ return true if construction == bse
812
+ end
813
+ end
814
+
815
+ false
816
+ end
817
+
818
+ ##
819
+ # Returns a surface's default construction set.
820
+ #
821
+ # @param s [OpenStudio::Model::Surface] a surface
822
+ #
823
+ # @return [OpenStudio::Model::DefaultConstructionSet] default set
824
+ # @return [nil] if invalid input (see logs)
825
+ def defaultConstructionSet(s = nil)
826
+ mth = "OSut::#{__callee__}"
827
+ cl = OpenStudio::Model::Surface
828
+ return invalid("surface", mth, 1) unless s.respond_to?(NS)
829
+
830
+ id = s.nameString
831
+ ok = s.isConstructionDefaulted
832
+ m1 = "#{id} construction not defaulted (#{mth})"
833
+ m2 = "#{id} construction"
834
+ m3 = "#{id} space"
835
+ return mismatch(id, s, cl, mth) unless s.is_a?(cl)
836
+
837
+ log(ERR, m1) unless ok
838
+ return nil unless ok
839
+ return empty(m2, mth, ERR) if s.construction.empty?
840
+ return empty(m3, mth, ERR) if s.space.empty?
841
+
842
+ mdl = s.model
843
+ base = s.construction.get
844
+ space = s.space.get
845
+ type = s.surfaceType
846
+ ground = false
847
+ exterior = false
848
+
849
+ if s.isGroundSurface
850
+ ground = true
851
+ elsif s.outsideBoundaryCondition.downcase == "outdoors"
852
+ exterior = true
853
+ end
854
+
855
+ unless space.defaultConstructionSet.empty?
856
+ set = space.defaultConstructionSet.get
857
+ return set if holdsConstruction?(set, base, ground, exterior, type)
858
+ end
859
+
860
+ unless space.spaceType.empty?
861
+ spacetype = space.spaceType.get
862
+
863
+ unless spacetype.defaultConstructionSet.empty?
864
+ set = spacetype.defaultConstructionSet.get
865
+ return set if holdsConstruction?(set, base, ground, exterior, type)
866
+ end
867
+ end
868
+
869
+ unless space.buildingStory.empty?
870
+ story = space.buildingStory.get
871
+
872
+ unless story.defaultConstructionSet.empty?
873
+ set = story.defaultConstructionSet.get
874
+ return set if holdsConstruction?(set, base, ground, exterior, type)
875
+ end
876
+ end
877
+
878
+ building = mdl.getBuilding
879
+
880
+ unless building.defaultConstructionSet.empty?
881
+ set = building.defaultConstructionSet.get
882
+ return set if holdsConstruction?(set, base, ground, exterior, type)
883
+ end
884
+
885
+ nil
886
+ end
887
+
888
+ ##
889
+ # Validates if every material in a layered construction is standard & opaque.
890
+ #
891
+ # @param lc [OpenStudio::LayeredConstruction] a layered construction
892
+ #
893
+ # @return [Bool] whether all layers are valid
894
+ # @return [false] if invalid input (see logs)
895
+ def standardOpaqueLayers?(lc = nil)
896
+ mth = "OSut::#{__callee__}"
897
+ cl = OpenStudio::Model::LayeredConstruction
898
+ return invalid("lc", mth, 1, DBG, false) unless lc.respond_to?(NS)
899
+ return mismatch(lc.nameString, lc, cl, mth, DBG, false) unless lc.is_a?(cl)
900
+
901
+ lc.layers.each { |m| return false if m.to_StandardOpaqueMaterial.empty? }
902
+
903
+ true
904
+ end
905
+
906
+ ##
907
+ # Returns total (standard opaque) layered construction thickness (m).
908
+ #
909
+ # @param lc [OpenStudio::LayeredConstruction] a layered construction
910
+ #
911
+ # @return [Float] construction thickness
912
+ # @return [0.0] if invalid input (see logs)
913
+ def thickness(lc = nil)
914
+ mth = "OSut::#{__callee__}"
915
+ cl = OpenStudio::Model::LayeredConstruction
916
+ return invalid("lc", mth, 1, DBG, 0.0) unless lc.respond_to?(NS)
917
+
918
+ id = lc.nameString
919
+ return mismatch(id, lc, cl, mth, DBG, 0.0) unless lc.is_a?(cl)
920
+
921
+ ok = standardOpaqueLayers?(lc)
922
+ log(ERR, "'#{id}' holds non-StandardOpaqueMaterial(s) (#{mth})") unless ok
923
+ return 0.0 unless ok
924
+
925
+ thickness = 0.0
926
+ lc.layers.each { |m| thickness += m.thickness }
927
+
928
+ thickness
929
+ end
930
+
931
+ ##
932
+ # Returns total air film resistance of a fenestrated construction (m2•K/W)
933
+ #
934
+ # @param usi [Numeric] a fenestrated construction's U-factor (W/m2•K)
935
+ #
936
+ # @return [Float] total air film resistances
937
+ # @return [0.1216] if invalid input (see logs)
938
+ def glazingAirFilmRSi(usi = 5.85)
939
+ # The sum of thermal resistances of calculated exterior and interior film
940
+ # coefficients under standard winter conditions are taken from:
941
+ #
942
+ # https://bigladdersoftware.com/epx/docs/9-6/engineering-reference/
943
+ # window-calculation-module.html#simple-window-model
944
+ #
945
+ # These remain acceptable approximations for flat windows, yet likely
946
+ # unsuitable for subsurfaces with curved or projecting shapes like domed
947
+ # skylights. The solution here is considered an adequate fix for reporting,
948
+ # awaiting eventual OpenStudio (and EnergyPlus) upgrades to report NFRC 100
949
+ # (or ISO) air film resistances under standard winter conditions.
950
+ #
951
+ # For U-factors above 8.0 W/m2•K (or invalid input), the function returns
952
+ # 0.1216 m2•K/W, which corresponds to a construction with a single glass
953
+ # layer thickness of 2mm & k = ~0.6 W/m.K.
954
+ #
955
+ # The EnergyPlus Engineering calculations were designed for vertical
956
+ # windows - not horizontal, slanted or domed surfaces - use with caution.
957
+ mth = "OSut::#{__callee__}"
958
+ cl = Numeric
959
+ return mismatch("usi", usi, cl, mth, DBG, 0.1216) unless usi.is_a?(cl)
960
+ return invalid("usi", mth, 1, WRN, 0.1216) if usi > 8.0
961
+ return negative("usi", mth, WRN, 0.1216) if usi < 0
962
+ return zero("usi", mth, WRN, 0.1216) if usi.abs < TOL
963
+
964
+ rsi = 1 / (0.025342 * usi + 29.163853) # exterior film, next interior film
965
+ return rsi + 1 / (0.359073 * Math.log(usi) + 6.949915) if usi < 5.85
966
+ return rsi + 1 / (1.788041 * usi - 2.886625)
967
+ end
968
+
969
+ ##
970
+ # Returns a construction's 'standard calc' thermal resistance (m2•K/W), which
971
+ # includes air film resistances. It excludes insulating effects of shades,
972
+ # screens, etc. in the case of fenestrated constructions.
973
+ #
974
+ # @param lc [OpenStudio::Model::LayeredConstruction] a layered construction
975
+ # @param film [Numeric] thermal resistance of surface air films (m2•K/W)
976
+ # @param t [Numeric] gas temperature (°C) (optional)
977
+ #
978
+ # @return [Float] layered construction's thermal resistance
979
+ # @return [0.0] if invalid input (see logs)
980
+ def rsi(lc = nil, film = 0.0, t = 0.0)
981
+ # This is adapted from BTAP's Material Module "get_conductance" (P. Lopez)
982
+ #
983
+ # https://github.com/NREL/OpenStudio-Prototype-Buildings/blob/
984
+ # c3d5021d8b7aef43e560544699fb5c559e6b721d/lib/btap/measures/
985
+ # btap_equest_converter/envelope.rb#L122
986
+ mth = "OSut::#{__callee__}"
987
+ cl1 = OpenStudio::Model::LayeredConstruction
988
+ cl2 = Numeric
989
+ return invalid("lc", mth, 1, DBG, 0.0) unless lc.respond_to?(NS)
990
+
991
+ id = lc.nameString
992
+ return mismatch(id, lc, cl1, mth, DBG, 0.0) unless lc.is_a?(cl1)
993
+ return mismatch("film", film, cl2, mth, DBG, 0.0) unless film.is_a?(cl2)
994
+ return mismatch("temp K", t, cl2, mth, DBG, 0.0) unless t.is_a?(cl2)
995
+
996
+ t += 273.0 # °C to K
997
+ return negative("temp K", mth, ERR, 0.0) if t < 0
998
+ return negative("film", mth, ERR, 0.0) if film < 0
999
+
1000
+ rsi = film
1001
+
1002
+ lc.layers.each do |m|
1003
+ # Fenestration materials first.
1004
+ empty = m.to_SimpleGlazing.empty?
1005
+ return 1 / m.to_SimpleGlazing.get.uFactor unless empty
1006
+
1007
+ empty = m.to_StandardGlazing.empty?
1008
+ rsi += m.to_StandardGlazing.get.thermalResistance unless empty
1009
+ empty = m.to_RefractionExtinctionGlazing.empty?
1010
+ rsi += m.to_RefractionExtinctionGlazing.get.thermalResistance unless empty
1011
+ empty = m.to_Gas.empty?
1012
+ rsi += m.to_Gas.get.getThermalResistance(t) unless empty
1013
+ empty = m.to_GasMixture.empty?
1014
+ rsi += m.to_GasMixture.get.getThermalResistance(t) unless empty
1015
+
1016
+ # Opaque materials next.
1017
+ empty = m.to_StandardOpaqueMaterial.empty?
1018
+ rsi += m.to_StandardOpaqueMaterial.get.thermalResistance unless empty
1019
+ empty = m.to_MasslessOpaqueMaterial.empty?
1020
+ rsi += m.to_MasslessOpaqueMaterial.get.thermalResistance unless empty
1021
+ empty = m.to_RoofVegetation.empty?
1022
+ rsi += m.to_RoofVegetation.get.thermalResistance unless empty
1023
+ empty = m.to_AirGap.empty?
1024
+ rsi += m.to_AirGap.get.thermalResistance unless empty
1025
+ end
1026
+
1027
+ rsi
1028
+ end
1029
+
1030
+ ##
1031
+ # Identifies a layered construction's (opaque) insulating layer. The method
1032
+ # returns a 3-keyed hash :index, the insulating layer index [0, n layers)
1033
+ # within the layered construction; :type, either :standard or :massless; and
1034
+ # :r, material thermal resistance in m2•K/W.
1035
+ #
1036
+ # @param lc [OpenStudio::Model::LayeredConstruction] a layered construction
1037
+ #
1038
+ # @return [Hash] index: (Integer), type: (Symbol), r: (Float)
1039
+ # @return [Hash] index: nil, type: nil, r: 0 if invalid input (see logs)
1040
+ def insulatingLayer(lc = nil)
1041
+ mth = "OSut::#{__callee__}"
1042
+ cl = OpenStudio::Model::LayeredConstruction
1043
+ res = { index: nil, type: nil, r: 0.0 }
1044
+ i = 0 # iterator
1045
+ return invalid("lc", mth, 1, DBG, res) unless lc.respond_to?(NS)
1046
+
1047
+ id = lc.nameString
1048
+ return mismatch(id, lc, cl1, mth, DBG, res) unless lc.is_a?(cl)
1049
+
1050
+ lc.layers.each do |m|
1051
+ unless m.to_MasslessOpaqueMaterial.empty?
1052
+ m = m.to_MasslessOpaqueMaterial.get
1053
+
1054
+ if m.thermalResistance < 0.001 || m.thermalResistance < res[:r]
1055
+ i += 1
1056
+ next
1057
+ else
1058
+ res[:r ] = m.thermalResistance
1059
+ res[:index] = i
1060
+ res[:type ] = :massless
1061
+ end
1062
+ end
1063
+
1064
+ unless m.to_StandardOpaqueMaterial.empty?
1065
+ m = m.to_StandardOpaqueMaterial.get
1066
+ k = m.thermalConductivity
1067
+ d = m.thickness
1068
+
1069
+ if d < 0.003 || k > 3.0 || d / k < res[:r]
1070
+ i += 1
1071
+ next
1072
+ else
1073
+ res[:r ] = d / k
1074
+ res[:index] = i
1075
+ res[:type ] = :standard
1076
+ end
1077
+ end
1078
+
1079
+ i += 1
1080
+ end
1081
+
1082
+ res
1083
+ end
1084
+
1085
+ ##
1086
+ # Validates whether opaque surface can be considered as a curtain wall (or
1087
+ # similar technology) spandrel, regardless of construction layers, by looking
1088
+ # up AdditionalProperties or its identifier.
1089
+ #
1090
+ # @param s [OpenStudio::Model::Surface] an opaque surface
1091
+ #
1092
+ # @return [Bool] whether surface can be considered 'spandrel'
1093
+ # @return [false] if invalid input (see logs)
1094
+ def spandrel?(s = nil)
1095
+ mth = "OSut::#{__callee__}"
1096
+ cl = OpenStudio::Model::Surface
1097
+ return invalid("surface", mth, 1, DBG, false) unless s.respond_to?(NS)
1098
+
1099
+ id = s.nameString
1100
+ m1 = "#{id}:spandrel"
1101
+ m2 = "#{id}:spandrel:boolean"
1102
+
1103
+ if s.additionalProperties.hasFeature("spandrel")
1104
+ val = s.additionalProperties.getFeatureAsBoolean("spandrel")
1105
+ return invalid(m1, mth, 1, ERR, false) if val.empty?
1106
+
1107
+ val = val.get
1108
+ return invalid(m2, mth, 1, ERR, false) unless [true, false].include?(val)
1109
+ return val
1110
+ end
1111
+
1112
+ id.downcase.include?("spandrel")
1113
+ end
1114
+
1115
+ # ---- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- ---- #
1116
+ # ---- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- ---- #
1117
+ # This next set of utilities (~850 lines) help distinguish spaces that are
1118
+ # directly vs indirectly CONDITIONED, vs SEMIHEATED. The solution here
49
1119
  # relies as much as possible on space conditioning categories found in
50
- # standards like ASHRAE 90.1 and energy codes like the Canadian NECB editions.
1120
+ # standards like ASHRAE 90.1 and energy codes like the Canadian NECBs.
1121
+ #
51
1122
  # Both documents share many similarities, regardless of nomenclature. There
52
1123
  # are however noticeable differences between approaches on how a space is
53
1124
  # tagged as falling into one of the aforementioned categories. First, an
@@ -69,11 +1140,11 @@ module OSut
69
1140
  #
70
1141
  # ... includes plenums, atria, etc.
71
1142
  #
72
- # - SEMI-HEATED space: an ENCLOSED space that has a heating system
1143
+ # - SEMIHEATED space: an ENCLOSED space that has a heating system
73
1144
  # >= 10 W/m2, yet NOT a CONDITIONED space (see above).
74
1145
  #
75
1146
  # - UNCONDITIONED space: an ENCLOSED space that is NOT a conditioned
76
- # space or a SEMI-HEATED space (see above).
1147
+ # space or a SEMIHEATED space (see above).
77
1148
  #
78
1149
  # NOTE: Crawlspaces, attics, and parking garages with natural or
79
1150
  # mechanical ventilation are considered UNENCLOSED spaces.
@@ -94,41 +1165,116 @@ module OSut
94
1165
  # to ASHRAE 90.1 (e.g. heating and/or cooling based, no distinction for
95
1166
  # INDIRECTLY conditioned spaces like plenums).
96
1167
  #
97
- # SEMI-HEATED spaces are also a defined NECB term, but again the distinction
98
- # is based on desired/intended design space setpoint temperatures - not
99
- # system sizing criteria. No further treatment is implemented here to
100
- # distinguish SEMI-HEATED from CONDITIONED spaces.
1168
+ # SEMIHEATED spaces are described in the NECB (yet not a defined term). The
1169
+ # distinction is also based on desired/intended design space setpoint
1170
+ # temperatures (here 15°C) - not system sizing criteria. No further treatment
1171
+ # is implemented here to distinguish SEMIHEATED from CONDITIONED spaces;
1172
+ # notwithstanding the AdditionalProperties tag (described further in this
1173
+ # section), it is up to users to determine if a CONDITIONED space is
1174
+ # indeed SEMIHEATED or not (e.g. based on MIN/MAX setpoints).
101
1175
  #
102
1176
  # The single NECB criterion distinguishing UNCONDITIONED ENCLOSED spaces
103
1177
  # (such as vestibules) from UNENCLOSED spaces (such as attics) remains the
104
1178
  # intention to ventilate - or rather to what degree. Regardless, the methods
105
- # here are designed to process both classifications in the same way, namely by
106
- # focusing on adjacent surfaces to CONDITIONED (or SEMI-HEATED) spaces as part
107
- # of the building envelope.
108
-
109
- # In light of the above, methods here are designed without a priori knowledge
110
- # of explicit system sizing choices or access to iterative autosizing
111
- # processes. As discussed in greater detail elswhere, methods are developed to
112
- # rely on zoning info and/or "intended" temperature setpoints.
1179
+ # here are designed to process both classifications in the same way, namely
1180
+ # by focusing on adjacent surfaces to CONDITIONED (or SEMIHEATED) spaces as
1181
+ # part of the building envelope.
1182
+
1183
+ # In light of the above, OSut methods here are designed without a priori
1184
+ # knowledge of explicit system sizing choices or access to iterative
1185
+ # autosizing processes. As discussed in greater detail below, methods here
1186
+ # are developed to rely on zoning and/or "intended" temperature setpoints.
1187
+ # In addition, OSut methods here cannot distinguish between UNCONDITIONED vs
1188
+ # UNENCLOSED spaces from OpenStudio geometry alone. They are henceforth
1189
+ # considered synonymous.
113
1190
  #
114
1191
  # For an OpenStudio model in an incomplete or preliminary state, e.g. holding
115
- # fully-formed ENCLOSED spaces without thermal zoning information or setpoint
116
- # temperatures (early design stage assessments of form, porosity or envelope),
117
- # all OpenStudio spaces will be considered CONDITIONED, presuming setpoints of
118
- # ~21°C (heating) and ~24°C (cooling).
1192
+ # fully-formed ENCLOSED spaces WITHOUT thermal zoning information or setpoint
1193
+ # temperatures (early design stage assessments of form, porosity or
1194
+ # envelope), OpenStudio spaces are considered CONDITIONED by default. This
1195
+ # default behaviour may be reset based on the (Space) AdditionalProperties
1196
+ # "space_conditioning_category" key (4x possible values), which is relied
1197
+ # upon by OpenStudio-Standards:
1198
+ #
1199
+ # github.com/NREL/openstudio-standards/blob/
1200
+ # d2b5e28928e712cb3f137ab5c1ad6d8889ca02b7/lib/openstudio-standards/
1201
+ # standards/Standards.Space.rb#L1604C5-L1605C1
1202
+ #
1203
+ # OpenStudio-Standards recognizes 4x possible value strings:
1204
+ # - "NonResConditioned"
1205
+ # - "ResConditioned"
1206
+ # - "Semiheated"
1207
+ # - "Unconditioned"
1208
+ #
1209
+ # OSut maintains existing "space_conditioning_category" key/value pairs
1210
+ # intact. Based on these, OSut methods may return related outputs:
1211
+ #
1212
+ # "space_conditioning_category" | OSut status | heating °C | cooling °C
1213
+ # ------------------------------- ------------- ---------- ----------
1214
+ # - "NonResConditioned" CONDITIONED 21.0 24.0
1215
+ # - "ResConditioned" CONDITIONED 21.0 24.0
1216
+ # - "Semiheated" SEMIHEATED 15.0 NA
1217
+ # - "Unconditioned" UNCONDITIONED NA NA
1218
+ #
1219
+ # OSut also looks up another (Space) AdditionalProperties 'key',
1220
+ # "indirectlyconditioned" to flag plenum or occupied spaces indirectly
1221
+ # conditioned with transfer air only. The only accepted 'value' for an
1222
+ # "indirectlyconditioned" 'key' is the name (string) of another (linked)
1223
+ # space, e.g.:
1224
+ #
1225
+ # "indirectlyconditioned" space | linked space, e.g. "core_space"
1226
+ # ------------------------------- ---------------------------------------
1227
+ # return air plenum occupied space below
1228
+ # supply air plenum occupied space above
1229
+ # dead air space (not a plenum) nearby occupied space
1230
+ #
1231
+ # OSut doesn't validate whether the "indirectlyconditioned" space is actually
1232
+ # adjacent to its linked space. It nonetheless relies on the latter's
1233
+ # conditioning category (e.g. CONDITIONED, SEMIHEATED) to determine
1234
+ # anticipated ambient temperatures in the former. For instance, an
1235
+ # "indirectlyconditioned"-tagged return air plenum linked to a SEMIHEATED
1236
+ # space is considered as free-floating in terms of cooling, and unlikely to
1237
+ # have ambient conditions below 15°C under heating (winter) design
1238
+ # conditions. OSut will associate this plenum to a 15°C heating setpoint
1239
+ # temperature. If the SEMIHEATED space instead has a heating setpoint
1240
+ # temperature of 7°C, then OSut will associate a 7°C heating setpoint to this
1241
+ # plenum.
1242
+ #
1243
+ # Even when a (more developed) OpenStudio model holds valid space/zone
1244
+ # temperature setpoints, OSut gives priority to these AdditionalProperties.
1245
+ # For instance, a CONDITIONED space can be considered INDIRECTLYCONDITIONED,
1246
+ # even if its zone thermostat has a valid heating and/or cooling setpoint.
1247
+ # This is in sync with OpenStudio-Standards' method
1248
+ # "space_conditioning_category()".
1249
+
1250
+ ##
1251
+ # Validates if model has zones with HVAC air loops.
1252
+ #
1253
+ # @param model [OpenStudio::Model::Model] a model
119
1254
  #
120
- # If ANY valid space/zone-specific temperature setpoints are found in the
121
- # OpenStudio model, spaces/zones WITHOUT valid heating or cooling setpoints
122
- # are considered as UNCONDITIONED or UNENCLOSED spaces (like attics), or
123
- # INDIRECTLY CONDITIONED spaces (like plenums), see "plenum?" method.
1255
+ # @return [Bool] whether model has HVAC air loops
1256
+ # @return [false] if invalid input (see logs)
1257
+ def airLoopsHVAC?(model = nil)
1258
+ mth = "OSut::#{__callee__}"
1259
+ cl = OpenStudio::Model::Model
1260
+ return mismatch("model", model, cl, mth, DBG, false) unless model.is_a?(cl)
1261
+
1262
+ model.getThermalZones.each do |zone|
1263
+ next if zone.canBePlenum
1264
+ return true unless zone.airLoopHVACs.empty?
1265
+ return true if zone.isPlenum
1266
+ end
1267
+
1268
+ false
1269
+ end
124
1270
 
125
1271
  ##
126
- # Return min & max values of a schedule (ruleset).
1272
+ # Returns MIN/MAX values of a schedule (ruleset).
127
1273
  #
128
- # @param sched [OpenStudio::Model::ScheduleRuleset] schedule
1274
+ # @param sched [OpenStudio::Model::ScheduleRuleset] a schedule
129
1275
  #
130
1276
  # @return [Hash] min: (Float), max: (Float)
131
- # @return [Hash] min: nil, max: nil (if invalid input)
1277
+ # @return [Hash] min: nil, max: nil if invalid inputs (see logs)
132
1278
  def scheduleRulesetMinMax(sched = nil)
133
1279
  # Largely inspired from David Goldwasser's
134
1280
  # "schedule_ruleset_annual_min_max_value":
@@ -139,44 +1285,28 @@ module OSut
139
1285
  mth = "OSut::#{__callee__}"
140
1286
  cl = OpenStudio::Model::ScheduleRuleset
141
1287
  res = { min: nil, max: nil }
142
-
143
- return invalid("sched", mth, 1, DBG, res) unless sched.respond_to?(NS)
1288
+ return invalid("sched", mth, 1, DBG, res) unless sched.respond_to?(NS)
144
1289
 
145
1290
  id = sched.nameString
146
1291
  return mismatch(id, sched, cl, mth, DBG, res) unless sched.is_a?(cl)
147
1292
 
148
- profiles = []
149
- profiles << sched.defaultDaySchedule
150
- sched.scheduleRules.each { |rule| profiles << rule.daySchedule }
1293
+ values = sched.defaultDaySchedule.values.to_a
151
1294
 
152
- profiles.each do |profile|
153
- id = profile.nameString
154
-
155
- profile.values.each do |val|
156
- ok = val.is_a?(Numeric)
157
- log(WRN, "Skipping non-numeric value in '#{id}' (#{mth})") unless ok
158
- next unless ok
159
-
160
- res[:min] = val unless res[:min]
161
- res[:min] = val if res[:min] > val
162
- res[:max] = val unless res[:max]
163
- res[:max] = val if res[:max] < val
164
- end
165
- end
1295
+ sched.scheduleRules.each { |rule| values += rule.daySchedule.values }
166
1296
 
167
- valid = res[:min] && res[:max]
168
- log(ERR, "Invalid MIN/MAX in '#{id}' (#{mth})") unless valid
1297
+ res[:min] = values.min.is_a?(Numeric) ? values.min : nil
1298
+ res[:max] = values.max.is_a?(Numeric) ? values.max : nil
169
1299
 
170
1300
  res
171
1301
  end
172
1302
 
173
1303
  ##
174
- # Return min & max values of a schedule (constant).
1304
+ # Returns MIN/MAX values of a schedule (constant).
175
1305
  #
176
- # @param sched [OpenStudio::Model::ScheduleConstant] schedule
1306
+ # @param sched [OpenStudio::Model::ScheduleConstant] a schedule
177
1307
  #
178
1308
  # @return [Hash] min: (Float), max: (Float)
179
- # @return [Hash] min: nil, max: nil (if invalid input)
1309
+ # @return [Hash] min: nil, max: nil if invalid inputs (see logs)
180
1310
  def scheduleConstantMinMax(sched = nil)
181
1311
  # Largely inspired from David Goldwasser's
182
1312
  # "schedule_constant_annual_min_max_value":
@@ -187,14 +1317,13 @@ module OSut
187
1317
  mth = "OSut::#{__callee__}"
188
1318
  cl = OpenStudio::Model::ScheduleConstant
189
1319
  res = { min: nil, max: nil }
190
-
191
- return invalid("sched", mth, 1, DBG, res) unless sched.respond_to?(NS)
1320
+ return invalid("sched", mth, 1, DBG, res) unless sched.respond_to?(NS)
192
1321
 
193
1322
  id = sched.nameString
194
1323
  return mismatch(id, sched, cl, mth, DBG, res) unless sched.is_a?(cl)
195
1324
 
196
- valid = sched.value.is_a?(Numeric)
197
- mismatch("'#{id}' value", sched.value, Numeric, mth, ERR, res) unless valid
1325
+ ok = sched.value.is_a?(Numeric)
1326
+ mismatch("#{id} value", sched.value, Numeric, mth, ERR, res) unless ok
198
1327
  res[:min] = sched.value
199
1328
  res[:max] = sched.value
200
1329
 
@@ -202,12 +1331,12 @@ module OSut
202
1331
  end
203
1332
 
204
1333
  ##
205
- # Return min & max values of a schedule (compact).
1334
+ # Returns MIN/MAX values of a schedule (compact).
206
1335
  #
207
1336
  # @param sched [OpenStudio::Model::ScheduleCompact] schedule
208
1337
  #
209
1338
  # @return [Hash] min: (Float), max: (Float)
210
- # @return [Hash] min: nil, max: nil (if invalid input)
1339
+ # @return [Hash] min: nil, max: nil if invalid input (see logs)
211
1340
  def scheduleCompactMinMax(sched = nil)
212
1341
  # Largely inspired from Andrew Parker's
213
1342
  # "schedule_compact_annual_min_max_value":
@@ -215,78 +1344,69 @@ module OSut
215
1344
  # github.com/NREL/openstudio-standards/blob/
216
1345
  # 99cf713750661fe7d2082739f251269c2dfd9140/lib/openstudio-standards/
217
1346
  # standards/Standards.ScheduleCompact.rb#L8
218
- mth = "OSut::#{__callee__}"
219
- cl = OpenStudio::Model::ScheduleCompact
220
- vals = []
221
- prev_str = ""
222
- res = { min: nil, max: nil }
223
-
224
- return invalid("sched", mth, 1, DBG, res) unless sched.respond_to?(NS)
1347
+ mth = "OSut::#{__callee__}"
1348
+ cl = OpenStudio::Model::ScheduleCompact
1349
+ vals = []
1350
+ prev = ""
1351
+ res = { min: nil, max: nil }
1352
+ return invalid("sched", mth, 1, DBG, res) unless sched.respond_to?(NS)
225
1353
 
226
1354
  id = sched.nameString
227
1355
  return mismatch(id, sched, cl, mth, DBG, res) unless sched.is_a?(cl)
228
1356
 
229
1357
  sched.extensibleGroups.each do |eg|
230
- if prev_str.include?("until")
1358
+ if prev.include?("until")
231
1359
  vals << eg.getDouble(0).get unless eg.getDouble(0).empty?
232
1360
  end
233
1361
 
234
- str = eg.getString(0)
235
- prev_str = str.get.downcase unless str.empty?
1362
+ str = eg.getString(0)
1363
+ prev = str.get.downcase unless str.empty?
236
1364
  end
237
1365
 
238
- return empty("'#{id}' values", mth, ERR, res) if vals.empty?
239
-
240
- ok = vals.min.is_a?(Numeric) && vals.max.is_a?(Numeric)
241
- log(ERR, "Non-numeric values in '#{id}' (#{mth})") unless ok
242
- return res unless ok
1366
+ return empty("#{id} values", mth, ERR, res) if vals.empty?
243
1367
 
244
- res[:min] = vals.min
245
- res[:max] = vals.max
1368
+ res[:min] = vals.min.is_a?(Numeric) ? vals.min : nil
1369
+ res[:max] = vals.min.is_a?(Numeric) ? vals.max : nil
246
1370
 
247
1371
  res
248
1372
  end
249
1373
 
250
1374
  ##
251
- # Return min & max values for schedule (interval).
1375
+ # Returns MIN/MAX values for schedule (interval).
252
1376
  #
253
1377
  # @param sched [OpenStudio::Model::ScheduleInterval] schedule
254
1378
  #
255
1379
  # @return [Hash] min: (Float), max: (Float)
256
- # @return [Hash] min: nil, max: nil (if invalid input)
1380
+ # @return [Hash] min: nil, max: nil if invalid input (see logs)
257
1381
  def scheduleIntervalMinMax(sched = nil)
258
1382
  mth = "OSut::#{__callee__}"
259
1383
  cl = OpenStudio::Model::ScheduleInterval
260
1384
  vals = []
261
1385
  res = { min: nil, max: nil }
262
-
263
- return invalid("sched", mth, 1, DBG, res) unless sched.respond_to?(NS)
1386
+ return invalid("sched", mth, 1, DBG, res) unless sched.respond_to?(NS)
264
1387
 
265
1388
  id = sched.nameString
266
1389
  return mismatch(id, sched, cl, mth, DBG, res) unless sched.is_a?(cl)
267
1390
 
268
1391
  vals = sched.timeSeries.values
269
- ok = vals.min.is_a?(Numeric) && vals.max.is_a?(Numeric)
270
- log(ERR, "Non-numeric values in '#{id}' (#{mth})") unless ok
271
- return res unless ok
272
1392
 
273
- res[:min] = vals.min
274
- res[:max] = vals.max
1393
+ res[:min] = vals.min.is_a?(Numeric) ? vals.min : nil
1394
+ res[:max] = vals.max.is_a?(Numeric) ? vals.min : nil
275
1395
 
276
1396
  res
277
1397
  end
278
1398
 
279
1399
  ##
280
- # Return max zone heating temperature schedule setpoint [°C] and whether
281
- # zone has active dual setpoint thermostat.
1400
+ # Returns MAX zone heating temperature schedule setpoint [°C] and whether
1401
+ # zone has an active dual setpoint thermostat.
282
1402
  #
283
1403
  # @param zone [OpenStudio::Model::ThermalZone] a thermal zone
284
1404
  #
285
1405
  # @return [Hash] spt: (Float), dual: (Bool)
286
- # @return [Hash] spt: nil, dual: false (if invalid input)
1406
+ # @return [Hash] spt: nil, dual: false if invalid input (see logs)
287
1407
  def maxHeatScheduledSetpoint(zone = nil)
288
1408
  # Largely inspired from Parker & Marrec's "thermal_zone_heated?" procedure.
289
- # The solution here is a tad more relaxed to encompass SEMI-HEATED zones as
1409
+ # The solution here is a tad more relaxed to encompass SEMIHEATED zones as
290
1410
  # per Canadian NECB criteria (basically any space with at least 10 W/m2 of
291
1411
  # installed heating equipement, i.e. below freezing in Canada).
292
1412
  #
@@ -296,8 +1416,7 @@ module OSut
296
1416
  mth = "OSut::#{__callee__}"
297
1417
  cl = OpenStudio::Model::ThermalZone
298
1418
  res = { spt: nil, dual: false }
299
-
300
- return invalid("zone", mth, 1, DBG, res) unless zone.respond_to?(NS)
1419
+ return invalid("zone", mth, 1, DBG, res) unless zone.respond_to?(NS)
301
1420
 
302
1421
  id = zone.nameString
303
1422
  return mismatch(id, zone, cl, mth, DBG, res) unless zone.is_a?(cl)
@@ -380,8 +1499,8 @@ module OSut
380
1499
 
381
1500
  return res if zone.thermostat.empty?
382
1501
 
383
- tstat = zone.thermostat.get
384
- res[:spt] = nil
1502
+ tstat = zone.thermostat.get
1503
+ res[:spt] = nil
385
1504
 
386
1505
  unless tstat.to_ThermostatSetpointDualSetpoint.empty? &&
387
1506
  tstat.to_ZoneControlThermostatStagedDualSetpoint.empty?
@@ -394,7 +1513,7 @@ module OSut
394
1513
 
395
1514
  unless tstat.heatingSetpointTemperatureSchedule.empty?
396
1515
  res[:dual] = true
397
- sched = tstat.heatingSetpointTemperatureSchedule.get
1516
+ sched = tstat.heatingSetpointTemperatureSchedule.get
398
1517
 
399
1518
  unless sched.to_ScheduleRuleset.empty?
400
1519
  sched = sched.to_ScheduleRuleset.get
@@ -453,16 +1572,15 @@ module OSut
453
1572
  end
454
1573
 
455
1574
  ##
456
- # Validate if model has zones with valid heating temperature setpoints.
1575
+ # Validates if model has zones with valid heating temperature setpoints.
457
1576
  #
458
1577
  # @param model [OpenStudio::Model::Model] a model
459
1578
  #
460
- # @return [Bool] true if valid heating temperature setpoints
461
- # @return [Bool] false if invalid input
1579
+ # @return [Bool] whether model holds valid heating temperature setpoints
1580
+ # @return [false] if invalid input (see logs)
462
1581
  def heatingTemperatureSetpoints?(model = nil)
463
1582
  mth = "OSut::#{__callee__}"
464
1583
  cl = OpenStudio::Model::Model
465
-
466
1584
  return mismatch("model", model, cl, mth, DBG, false) unless model.is_a?(cl)
467
1585
 
468
1586
  model.getThermalZones.each do |zone|
@@ -473,13 +1591,13 @@ module OSut
473
1591
  end
474
1592
 
475
1593
  ##
476
- # Return min zone cooling temperature schedule setpoint [°C] and whether
477
- # zone has active dual setpoint thermostat.
1594
+ # Returns MIN zone cooling temperature schedule setpoint [°C] and whether
1595
+ # zone has an active dual setpoint thermostat.
478
1596
  #
479
1597
  # @param zone [OpenStudio::Model::ThermalZone] a thermal zone
480
1598
  #
481
1599
  # @return [Hash] spt: (Float), dual: (Bool)
482
- # @return [Hash] spt: nil, dual: false (if invalid input)
1600
+ # @return [Hash] spt: nil, dual: false if invalid input (see logs)
483
1601
  def minCoolScheduledSetpoint(zone = nil)
484
1602
  # Largely inspired from Parker & Marrec's "thermal_zone_cooled?" procedure.
485
1603
  #
@@ -489,8 +1607,7 @@ module OSut
489
1607
  mth = "OSut::#{__callee__}"
490
1608
  cl = OpenStudio::Model::ThermalZone
491
1609
  res = { spt: nil, dual: false }
492
-
493
- return invalid("zone", mth, 1, DBG, res) unless zone.respond_to?(NS)
1610
+ return invalid("zone", mth, 1, DBG, res) unless zone.respond_to?(NS)
494
1611
 
495
1612
  id = zone.nameString
496
1613
  return mismatch(id, zone, cl, mth, DBG, res) unless zone.is_a?(cl)
@@ -560,8 +1677,8 @@ module OSut
560
1677
 
561
1678
  return res if zone.thermostat.empty?
562
1679
 
563
- tstat = zone.thermostat.get
564
- res[:spt] = nil
1680
+ tstat = zone.thermostat.get
1681
+ res[:spt] = nil
565
1682
 
566
1683
  unless tstat.to_ThermostatSetpointDualSetpoint.empty? &&
567
1684
  tstat.to_ZoneControlThermostatStagedDualSetpoint.empty?
@@ -574,7 +1691,7 @@ module OSut
574
1691
 
575
1692
  unless tstat.coolingSetpointTemperatureSchedule.empty?
576
1693
  res[:dual] = true
577
- sched = tstat.coolingSetpointTemperatureSchedule.get
1694
+ sched = tstat.coolingSetpointTemperatureSchedule.get
578
1695
 
579
1696
  unless sched.to_ScheduleRuleset.empty?
580
1697
  sched = sched.to_ScheduleRuleset.get
@@ -633,17 +1750,16 @@ module OSut
633
1750
  end
634
1751
 
635
1752
  ##
636
- # Validate if model has zones with valid cooling temperature setpoints.
1753
+ # Validates if model has zones with valid cooling temperature setpoints.
637
1754
  #
638
1755
  # @param model [OpenStudio::Model::Model] a model
639
1756
  #
640
- # @return [Bool] true if valid cooling temperature setpoints
641
- # @return [Bool] false if invalid input
1757
+ # @return [Bool] whether model holds valid cooling temperature setpoints
1758
+ # @return [false] if invalid input (see logs)
642
1759
  def coolingTemperatureSetpoints?(model = nil)
643
1760
  mth = "OSut::#{__callee__}"
644
1761
  cl = OpenStudio::Model::Model
645
-
646
- return mismatch("model", model, cl, mth, DBG, false) unless model.is_a?(cl)
1762
+ return mismatch("model", model, cl, mth, DBG, false) unless model.is_a?(cl)
647
1763
 
648
1764
  model.getThermalZones.each do |zone|
649
1765
  return true if minCoolScheduledSetpoint(zone)[:spt]
@@ -653,117 +1769,318 @@ module OSut
653
1769
  end
654
1770
 
655
1771
  ##
656
- # Validate if model has zones with HVAC air loops.
1772
+ # Validates whether space is a vestibule.
657
1773
  #
658
- # @param model [OpenStudio::Model::Model] a model
1774
+ # @param space [OpenStudio::Model::Space] a space
659
1775
  #
660
- # @return [Bool] true if model has one or more HVAC air loops
661
- # @return [Bool] false if invalid input
662
- def airLoopsHVAC?(model = nil)
1776
+ # @return [Bool] whether space is considered a vestibule
1777
+ # @return [false] if invalid input (see logs)
1778
+ def vestibule?(space = nil)
1779
+ # INFO: OpenStudio-Standards' "thermal_zone_vestibule" criteria:
1780
+ # - zones less than 200ft2; AND
1781
+ # - having infiltration using Design Flow Rate
1782
+ #
1783
+ # github.com/NREL/openstudio-standards/blob/
1784
+ # 86bcd026a20001d903cc613bed6d63e94b14b142/lib/openstudio-standards/
1785
+ # standards/Standards.ThermalZone.rb#L1264
1786
+ #
1787
+ # This (unused) OpenStudio-Standards method likely needs revision; it would
1788
+ # return "false" if the thermal zone area were less than 200ft2. Not sure
1789
+ # which edition of 90.1 relies on a 200ft2 threshold (2010?); 90.1 2016
1790
+ # doesn't. Yet even fixed, the method would nonetheless misidentify as
1791
+ # "vestibule" a small space along an exterior wall, such as a semiheated
1792
+ # storage space.
1793
+ #
1794
+ # The code below is intended as a simple short-term solution, basically
1795
+ # relying on AdditionalProperties, or (if missing) a "vestibule" substring
1796
+ # within a space's spaceType name (or the latter's standardsSpaceType).
1797
+ #
1798
+ # Alternatively, some future method could infer its status as a vestibule
1799
+ # based on a few basic features (common to all vintages):
1800
+ # - 1x+ outdoor-facing wall(s) holding 1x+ door(s)
1801
+ # - adjacent to 1x+ 'occupied' conditioned space(s)
1802
+ # - ideally, 1x+ door(s) between vestibule and 1x+ such adjacent space(s)
1803
+ #
1804
+ # An additional method parameter (i.e. std = :necb) could be added to
1805
+ # ensure supplementary Standard-specific checks, e.g. maximum floor area,
1806
+ # minimum distance between doors.
1807
+ #
1808
+ # Finally, an entirely separate method could be developed to first identify
1809
+ # whether "building entrances" (a defined term in 90.1) actually require
1810
+ # vestibules as per specific code requirements. Food for thought.
663
1811
  mth = "OSut::#{__callee__}"
664
- cl = OpenStudio::Model::Model
1812
+ cl = OpenStudio::Model::Space
1813
+ return mismatch("space", space, cl, mth, DBG, false) unless space.is_a?(cl)
665
1814
 
666
- return mismatch("model", model, cl, mth, DBG, false) unless model.is_a?(cl)
1815
+ id = space.nameString
1816
+ m1 = "#{id}:vestibule"
1817
+ m2 = "#{id}:vestibule:boolean"
667
1818
 
668
- model.getThermalZones.each do |zone|
669
- next if zone.canBePlenum
670
- return true unless zone.airLoopHVACs.empty?
671
- return true if zone.isPlenum
1819
+ if space.additionalProperties.hasFeature("vestibule")
1820
+ val = space.additionalProperties.getFeatureAsBoolean("vestibule")
1821
+ return invalid(m1, mth, 1, ERR, false) if val.empty?
1822
+
1823
+ val = val.get
1824
+ return invalid(m2, mth, 1, ERR, false) unless [true, false].include?(val)
1825
+ return val
1826
+ end
1827
+
1828
+ unless space.spaceType.empty?
1829
+ type = space.spaceType.get
1830
+ return false if type.nameString.downcase.include?("plenum")
1831
+ return true if type.nameString.downcase.include?("vestibule")
1832
+
1833
+ unless type.standardsSpaceType.empty?
1834
+ type = type.standardsSpaceType.get.downcase
1835
+ return false if type.include?("plenum")
1836
+ return true if type.include?("vestibule")
1837
+ end
672
1838
  end
673
1839
 
674
1840
  false
675
1841
  end
676
1842
 
677
1843
  ##
678
- # Validate whether space should be processed as a plenum.
1844
+ # Validates whether a space is an indirectly-conditioned plenum.
679
1845
  #
680
1846
  # @param space [OpenStudio::Model::Space] a space
681
- # @param loops [Bool] true if model has airLoopHVAC object(s)
682
- # @param setpoints [Bool] true if model has valid temperature setpoints
683
1847
  #
684
- # @return [Bool] true if should be tagged as plenum
685
- # @return [Bool] false if invalid input
686
- def plenum?(space = nil, loops = nil, setpoints = nil)
687
- # Largely inspired from NREL's "space_plenum?" procedure:
1848
+ # @return [Bool] whether space is considered a plenum
1849
+ # @return [false] if invalid input (see logs)
1850
+ def plenum?(space = nil)
1851
+ # Largely inspired from NREL's "space_plenum?":
688
1852
  #
689
- # github.com/NREL/openstudio-standards/blob/
690
- # 58964222d25783e9da4ae292e375fb0d5c902aa5/lib/openstudio-standards/
691
- # standards/Standards.Space.rb#L1384
1853
+ # github.com/NREL/openstudio-standards/blob/
1854
+ # 58964222d25783e9da4ae292e375fb0d5c902aa5/lib/openstudio-standards/
1855
+ # standards/Standards.Space.rb#L1384
1856
+ #
1857
+ # Ideally, "plenum?" should be in sync with OpenStudio SDK's "isPlenum"
1858
+ # method, which solely looks for either HVAC air mixer objects:
1859
+ # - AirLoopHVACReturnPlenum
1860
+ # - AirLoopHVACSupplyPlenum
1861
+ #
1862
+ # Of the OpenStudio-Standards Prototype models, only the LargeOffice
1863
+ # holds AirLoopHVACReturnPlenum objects. OpenStudio-Standards' method
1864
+ # "space_plenum?" indeed catches them by checking if the space is
1865
+ # "partofTotalFloorArea" (which internally has an "isPlenum" check). So
1866
+ # "isPlenum" closely follows ASHRAE 90.1 2016's definition of "plenum":
1867
+ #
1868
+ # "plenum": a compartment or chamber ...
1869
+ # - to which one or more ducts are connected
1870
+ # - that forms a part of the air distribution system, and
1871
+ # - that is NOT USED for occupancy or storage.
1872
+ #
1873
+ # Canadian NECB 2020 has the following (not as well) defined term:
1874
+ # "plenum": a chamber forming part of an air duct system.
1875
+ # ... we'll assume that a space shall also be considered
1876
+ # UNOCCUPIED if it's "part of an air duct system".
1877
+ #
1878
+ # As intended, "isPlenum" would NOT identify as a "plenum" any vented
1879
+ # UNCONDITIONED or UNENCLOSED attic or crawlspace - good. Yet "isPlenum"
1880
+ # would also ignore dead air spaces integrating ducted return air. The
1881
+ # SDK's "partofTotalFloorArea" would be more suitable in such cases, as
1882
+ # long as modellers have, a priori, set this parameter to FALSE.
1883
+ #
1884
+ # OpenStudio-Standards' "space_plenum?" catches a MUCH WIDER range of
1885
+ # spaces, which aren't caught by "isPlenum". This includes attics,
1886
+ # crawlspaces, non-plenum air spaces above ceiling tiles, and any other
1887
+ # UNOCCUPIED space in a model. The term "plenum" in this context is more
1888
+ # of a catch-all shorthand - to be used with caution. For instance,
1889
+ # "space_plenum?" shouldn't be used (in isolation) to determine whether an
1890
+ # UNOCCUPIED space should have its envelope insulated ("plenum") or not
1891
+ # ("attic").
1892
+ #
1893
+ # In contrast to OpenStudio-Standards' "space_plenum?", this method
1894
+ # strictly returns FALSE if a space is indeed "partofTotalFloorArea". It
1895
+ # also returns FALSE if the space is a vestibule. Otherwise, it needs more
1896
+ # information to determine if such an UNOCCUPIED space is indeed a
1897
+ # plenum. Beyond these 2x criteria, a space is considered a plenum if:
692
1898
  #
693
- # A space may be tagged as a plenum if:
1899
+ # CASE A: it includes the substring "plenum" (case insensitive) in its
1900
+ # spaceType's name, or in the latter's standardsSpaceType string;
694
1901
  #
695
- # CASE A: its zone's "isPlenum" == true (SDK method) for a fully-developed
696
- # OpenStudio model (complete with HVAC air loops); OR
1902
+ # CASE B: "isPlenum" == TRUE in an OpenStudio model WITH HVAC airloops: OR
697
1903
  #
698
- # CASE B: (IN ABSENCE OF HVAC AIRLOOPS) if it's excluded from a building's
699
- # total floor area yet linked to a zone holding an 'inactive'
700
- # thermostat, i.e. can't extract valid setpoints; OR
1904
+ # CASE C: its zone holds an 'inactive' thermostat (i.e. can't extract valid
1905
+ # setpoints) in an OpenStudio model with setpoint temperatures.
701
1906
  #
702
- # CASE C: (IN ABSENCE OF HVAC AIRLOOPS & VALID SETPOINTS) it has "plenum"
703
- # (case insensitive) as a spacetype (or as a spacetype's
704
- # 'standards spacetype').
1907
+ # If a modeller is instead simply interested in identifying UNOCCUPIED
1908
+ # spaces that are INDIRECTLYCONDITIONED (not necessarily plenums), then the
1909
+ # following combination is likely more reliable and less confusing:
1910
+ # - SDK's partofTotalFloorArea == FALSE
1911
+ # - OSut's unconditioned? == FALSE
705
1912
  mth = "OSut::#{__callee__}"
706
1913
  cl = OpenStudio::Model::Space
707
-
708
- return invalid("space", mth, 1, DBG, false) unless space.respond_to?(NS)
1914
+ return mismatch("space", space, cl, mth, DBG, false) unless space.is_a?(cl)
1915
+ return false if space.partofTotalFloorArea
1916
+ return false if vestibule?(space)
709
1917
 
710
1918
  id = space.nameString
711
- return mismatch(id, space, cl, mth, DBG, false) unless space.is_a?(cl)
1919
+ m1 = "#{id}:plenum"
1920
+ m1 = "#{id}:plenum boolean"
1921
+
1922
+ # CASE A: "plenum" spaceType.
1923
+ unless space.spaceType.empty?
1924
+ type = space.spaceType.get
1925
+ return true if type.nameString.downcase.include?("plenum")
1926
+
1927
+ unless type.standardsSpaceType.empty?
1928
+ type = type.standardsSpaceType.get.downcase
1929
+ return true if type.include?("plenum")
1930
+ end
1931
+ end
1932
+
1933
+ # CASE B: "isPlenum" == TRUE if airloops.
1934
+ return space.isPlenum if airLoopsHVAC?(space.model)
1935
+
1936
+ # CASE C: zone holds an 'inactive' thermostat.
1937
+ zone = space.thermalZone
1938
+ heated = heatingTemperatureSetpoints?(space.model)
1939
+ cooled = coolingTemperatureSetpoints?(space.model)
1940
+
1941
+ if heated || cooled
1942
+ return false if zone.empty?
1943
+
1944
+ zone = zone.get
1945
+ heat = maxHeatScheduledSetpoint(zone)
1946
+ cool = minCoolScheduledSetpoint(zone)
1947
+ return false if heat[:spt] || cool[:spt] # directly CONDITIONED
1948
+ return heat[:dual] || cool[:dual] # FALSE if both are nilled
1949
+ end
1950
+
1951
+ false
1952
+ end
1953
+
1954
+ ##
1955
+ # Retrieves a space's (implicit or explicit) heating/cooling setpoints.
1956
+ #
1957
+ # @param space [OpenStudio::Model::Space] a space
1958
+ #
1959
+ # @return [Hash] heating: (Float), cooling: (Float)
1960
+ # @return [Hash] heating: nil, cooling: nil if invalid input (see logs)
1961
+ def setpoints(space = nil)
1962
+ mth = "OSut::#{__callee__}"
1963
+ cl1 = OpenStudio::Model::Space
1964
+ cl2 = String
1965
+ res = {heating: nil, cooling: nil}
1966
+ tg1 = "space_conditioning_category"
1967
+ tg2 = "indirectlyconditioned"
1968
+ cts = ["nonresconditioned", "resconditioned", "semiheated", "unconditioned"]
1969
+ cnd = nil
1970
+ return mismatch("space", space, cl1, mth, DBG, res) unless space.is_a?(cl1)
1971
+
1972
+ # 1. Check for OpenStudio-Standards' space conditioning categories.
1973
+ if space.additionalProperties.hasFeature(tg1)
1974
+ cnd = space.additionalProperties.getFeatureAsString(tg1)
1975
+
1976
+ if cnd.empty?
1977
+ cnd = nil
1978
+ else
1979
+ cnd = cnd.get
712
1980
 
713
- valid = loops == true || loops == false
714
- return invalid("loops", mth, 2, DBG, false) unless valid
1981
+ if cts.include?(cnd.downcase)
1982
+ return res if cnd.downcase == "unconditioned"
1983
+ else
1984
+ invalid("#{tg1}:#{cnd}", mth, 0, ERR)
1985
+ cnd = nil
1986
+ end
1987
+ end
1988
+ end
715
1989
 
716
- valid = setpoints == true || setpoints == false
717
- return invalid("setpoints", mth, 3, DBG, false) unless valid
1990
+ # 2. Check instead OSut's INDIRECTLYCONDITIONED (parent space) link.
1991
+ if cnd.nil?
1992
+ id = space.additionalProperties.getFeatureAsString(tg2)
718
1993
 
719
- unless space.thermalZone.empty?
720
- zone = space.thermalZone.get
721
- return zone.isPlenum if loops # A
1994
+ unless id.empty?
1995
+ id = id.get
1996
+ dad = space.model.getSpaceByName(id)
722
1997
 
723
- if setpoints
724
- heat = maxHeatScheduledSetpoint(zone)
725
- cool = minCoolScheduledSetpoint(zone)
726
- return false if heat[:spt] || cool[:spt] # directly conditioned
727
- return heat[:dual] || cool[:dual] unless space.partofTotalFloorArea # B
728
- return false
1998
+ if dad.empty?
1999
+ log(ERR, "Unknown space #{id} (#{mth})")
2000
+ else
2001
+ # Now focus on 'parent' space linked to INDIRECTLYCONDITIONED space.
2002
+ space = dad.get
2003
+ cnd = tg2
2004
+ end
729
2005
  end
730
2006
  end
731
2007
 
732
- unless space.spaceType.empty?
733
- type = space.spaceType.get
734
- return type.nameString.downcase == "plenum" # C
2008
+ # 3. Fetch space setpoints (if model indeed holds valid setpoints).
2009
+ heated = heatingTemperatureSetpoints?(space.model)
2010
+ cooled = coolingTemperatureSetpoints?(space.model)
2011
+ zone = space.thermalZone
2012
+
2013
+ if heated || cooled
2014
+ return res if zone.empty? # UNCONDITIONED
2015
+
2016
+ zone = zone.get
2017
+ res[:heating] = maxHeatScheduledSetpoint(zone)[:spt]
2018
+ res[:cooling] = minCoolScheduledSetpoint(zone)[:spt]
735
2019
  end
736
2020
 
737
- unless type.standardsSpaceType.empty?
738
- type = type.standardsSpaceType.get
739
- return type.downcase == "plenum" # C
2021
+ # 4. Reset if AdditionalProperties were found & valid.
2022
+ unless cnd.nil?
2023
+ if cnd.downcase == "unconditioned"
2024
+ res[:heating] = nil
2025
+ res[:cooling] = nil
2026
+ elsif cnd.downcase == "semiheated"
2027
+ res[:heating] = 15.0 if res[:heating].nil?
2028
+ res[:cooling] = nil
2029
+ elsif cnd.downcase.include?("conditioned")
2030
+ # "nonresconditioned", "resconditioned" or "indirectlyconditioned"
2031
+ res[:heating] = 21.0 if res[:heating].nil? # default
2032
+ res[:cooling] = 24.0 if res[:cooling].nil? # default
2033
+ end
740
2034
  end
741
2035
 
742
- false
2036
+ # 5. Reset if plenum?
2037
+ if plenum?(space)
2038
+ res[:heating] = 21.0 if res[:heating].nil? # default
2039
+ res[:cooling] = 24.0 if res[:cooling].nil? # default
2040
+ end
2041
+
2042
+ res
2043
+ end
2044
+
2045
+ ##
2046
+ # Validates if a space is UNCONDITIONED.
2047
+ #
2048
+ # @param space [OpenStudio::Model::Space] a space
2049
+ #
2050
+ # @return [Bool] whether space is considered UNCONDITIONED
2051
+ # @return [false] if invalid input (see logs)
2052
+ def unconditioned?(space = nil)
2053
+ mth = "OSut::#{__callee__}"
2054
+ cl = OpenStudio::Model::Space
2055
+ return mismatch("space", space, cl, mth, DBG, false) unless space.is_a?(cl)
2056
+
2057
+ ok = false
2058
+ ok = setpoints(space)[:heating].nil? && setpoints(space)[:cooling].nil?
2059
+
2060
+ ok
743
2061
  end
744
2062
 
745
2063
  ##
746
- # Generate an HVAC availability schedule.
2064
+ # Generates an HVAC availability schedule.
747
2065
  #
748
2066
  # @param model [OpenStudio::Model::Model] a model
749
2067
  # @param avl [String] seasonal availability choice (optional, default "ON")
750
2068
  #
751
2069
  # @return [OpenStudio::Model::Schedule] HVAC availability sched
752
- # @return [NilClass] if invalid input
2070
+ # @return [nil] if invalid input (see logs)
753
2071
  def availabilitySchedule(model = nil, avl = "")
754
2072
  mth = "OSut::#{__callee__}"
755
2073
  cl = OpenStudio::Model::Model
756
2074
  limits = nil
757
-
758
- return mismatch("model", model, cl, mth) unless model.is_a?(cl)
759
- return invalid("availability", avl, 2, mth) unless avl.respond_to?(:to_s)
2075
+ return mismatch("model", model, cl, mth) unless model.is_a?(cl)
2076
+ return invalid("availability", avl, 2, mth) unless avl.respond_to?(:to_s)
760
2077
 
761
2078
  # Either fetch availability ScheduleTypeLimits object, or create one.
762
2079
  model.getScheduleTypeLimitss.each do |l|
763
- break if limits
764
- next if l.lowerLimitValue.empty?
765
- next if l.upperLimitValue.empty?
766
- next if l.numericType.empty?
2080
+ break if limits
2081
+ next if l.lowerLimitValue.empty?
2082
+ next if l.upperLimitValue.empty?
2083
+ next if l.numericType.empty?
767
2084
  next unless l.lowerLimitValue.get.to_i == 0
768
2085
  next unless l.upperLimitValue.get.to_i == 1
769
2086
  next unless l.numericType.get.downcase == "discrete"
@@ -789,35 +2106,35 @@ module OSut
789
2106
 
790
2107
  # Seasonal availability start/end dates.
791
2108
  year = model.yearDescription
792
- return empty("yearDescription", mth, ERR) if year.empty?
2109
+ return empty("yearDescription", mth, ERR) if year.empty?
793
2110
 
794
2111
  year = year.get
795
2112
  may01 = year.makeDate(OpenStudio::MonthOfYear.new("May"), 1)
796
2113
  oct31 = year.makeDate(OpenStudio::MonthOfYear.new("Oct"), 31)
797
2114
 
798
- case avl.to_s.downcase
799
- when "winter" # available from November 1 to April 30 (6 months)
2115
+ case trim(avl).downcase
2116
+ when "winter" # available from November 1 to April 30 (6 months)
800
2117
  val = 1
801
2118
  sch = off
802
2119
  nom = "WINTER Availability SchedRuleset"
803
2120
  dft = "WINTER Availability dftDaySched"
804
2121
  tag = "May-Oct WINTER Availability SchedRule"
805
2122
  day = "May-Oct WINTER SchedRule Day"
806
- when "summer" # available from May 1 to October 31 (6 months)
2123
+ when "summer" # available from May 1 to October 31 (6 months)
807
2124
  val = 0
808
2125
  sch = on
809
2126
  nom = "SUMMER Availability SchedRuleset"
810
2127
  dft = "SUMMER Availability dftDaySched"
811
2128
  tag = "May-Oct SUMMER Availability SchedRule"
812
2129
  day = "May-Oct SUMMER SchedRule Day"
813
- when "off" # never available
2130
+ when "off" # never available
814
2131
  val = 0
815
2132
  sch = on
816
2133
  nom = "OFF Availability SchedRuleset"
817
2134
  dft = "OFF Availability dftDaySched"
818
2135
  tag = ""
819
2136
  day = ""
820
- else # always available
2137
+ else # always available
821
2138
  val = 1
822
2139
  sch = on
823
2140
  nom = "ON Availability SchedRuleset"
@@ -835,14 +2152,14 @@ module OSut
835
2152
 
836
2153
  unless schedule.empty?
837
2154
  schedule = schedule.get
838
- default = schedule.defaultDaySchedule
2155
+ default = schedule.defaultDaySchedule
839
2156
  ok = ok && default.nameString == dft
840
2157
  ok = ok && default.times.size == 1
841
2158
  ok = ok && default.values.size == 1
842
2159
  ok = ok && default.times.first == time
843
2160
  ok = ok && default.values.first == val
844
2161
  rules = schedule.scheduleRules
845
- ok = ok && (rules.size == 0 || rules.size == 1)
2162
+ ok = ok && rules.size < 2
846
2163
 
847
2164
  if rules.size == 1
848
2165
  rule = rules.first
@@ -867,30 +2184,37 @@ module OSut
867
2184
 
868
2185
  schedule = OpenStudio::Model::ScheduleRuleset.new(model)
869
2186
  schedule.setName(nom)
870
- ok = schedule.setScheduleTypeLimits(limits)
871
- log(ERR, "'#{nom}': Can't set schedule type limits (#{mth})") unless ok
872
- return nil unless ok
873
2187
 
874
- ok = schedule.defaultDaySchedule.addValue(time, val)
875
- log(ERR, "'#{nom}': Can't set default day schedule (#{mth})") unless ok
876
- return nil unless ok
2188
+ unless schedule.setScheduleTypeLimits(limits)
2189
+ log(ERR, "'#{nom}': Can't set schedule type limits (#{mth})")
2190
+ return nil
2191
+ end
2192
+
2193
+ unless schedule.defaultDaySchedule.addValue(time, val)
2194
+ log(ERR, "'#{nom}': Can't set default day schedule (#{mth})")
2195
+ return nil
2196
+ end
877
2197
 
878
2198
  schedule.defaultDaySchedule.setName(dft)
879
2199
 
880
2200
  unless tag.empty?
881
2201
  rule = OpenStudio::Model::ScheduleRule.new(schedule, sch)
882
2202
  rule.setName(tag)
883
- ok = rule.setStartDate(may01)
884
- log(ERR, "'#{tag}': Can't set start date (#{mth})") unless ok
885
- return nil unless ok
886
2203
 
887
- ok = rule.setEndDate(oct31)
888
- log(ERR, "'#{tag}': Can't set end date (#{mth})") unless ok
889
- return nil unless ok
2204
+ unless rule.setStartDate(may01)
2205
+ log(ERR, "'#{tag}': Can't set start date (#{mth})")
2206
+ return nil
2207
+ end
2208
+
2209
+ unless rule.setEndDate(oct31)
2210
+ log(ERR, "'#{tag}': Can't set end date (#{mth})")
2211
+ return nil
2212
+ end
890
2213
 
891
- ok = rule.setApplyAllDays(true)
892
- log(ERR, "'#{tag}': Can't apply to all days (#{mth})") unless ok
893
- return nil unless ok
2214
+ unless rule.setApplyAllDays(true)
2215
+ log(ERR, "'#{tag}': Can't apply to all days (#{mth})")
2216
+ return nil
2217
+ end
894
2218
 
895
2219
  rule.daySchedule.setName(day)
896
2220
  end
@@ -898,615 +2222,756 @@ module OSut
898
2222
  schedule
899
2223
  end
900
2224
 
2225
+ # ---- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- ---- #
2226
+ # ---- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- ---- #
2227
+ # This final set of utilities targets OpenStudio geometry. Many of the
2228
+ # following geometry methods rely on Boost as an OpenStudio dependency.
2229
+ # As per Boost requirements, points (e.g. polygons) must first be 'aligned':
2230
+ # - first rotated/tilted as to lay flat along XY plane (Z-axis ~= 0)
2231
+ # - initial Z-axis values are represented as Y-axis values
2232
+ # - points with the lowest X-axis values are 'aligned' along X-axis (0)
2233
+ # - points with the lowest Z-axis values are 'aligned' along Y-axis (0)
2234
+ # - for several Boost methods, points must be clockwise in sequence
2235
+ #
2236
+ # Check OSut's poly() method, which offers such Boost-related options.
2237
+
901
2238
  ##
902
- # Validate if default construction set holds a base construction.
2239
+ # Returns OpenStudio site/space transformation & rotation angle [0,2PI) rads.
903
2240
  #
904
- # @param set [OpenStudio::Model::DefaultConstructionSet] a default set
905
- # @param bse [OpensStudio::Model::ConstructionBase] a construction base
906
- # @param gr [Bool] true if ground-facing surface
907
- # @param ex [Bool] true if exterior-facing surface
908
- # @param typ [String] a surface type
2241
+ # @param group [OpenStudio::Model::PlanarSurfaceGroup] a site or space object
909
2242
  #
910
- # @return [Bool] true if default construction set holds construction
911
- # @return [Bool] false if invalid input
912
- def holdsConstruction?(set = nil, bse = nil, gr = false, ex = false, typ = "")
2243
+ # @return [Hash] t: (OpenStudio::Transformation), r: (Float)
2244
+ # @return [Hash] t: nil, r: nil if invalid input (see logs)
2245
+ def transforms(group = nil)
913
2246
  mth = "OSut::#{__callee__}"
914
- cl1 = OpenStudio::Model::DefaultConstructionSet
915
- cl2 = OpenStudio::Model::ConstructionBase
916
-
917
- return invalid("set", mth, 1, DBG, false) unless set.respond_to?(NS)
918
-
919
- id = set.nameString
920
- return mismatch(id, set, cl1, mth, DBG, false) unless set.is_a?(cl1)
921
- return invalid("base", mth, 2, DBG, false) unless bse.respond_to?(NS)
922
-
923
- id = bse.nameString
924
- return mismatch(id, bse, cl2, mth, DBG, false) unless bse.is_a?(cl2)
925
-
926
- valid = gr == true || gr == false
927
- return invalid("ground", mth, 3, DBG, false) unless valid
928
-
929
- valid = ex == true || ex == false
930
- return invalid("exterior", mth, 4, DBG, false) unless valid
931
-
932
- valid = typ.respond_to?(:to_s)
933
- return invalid("surface typ", mth, 4, DBG, false) unless valid
934
-
935
- type = typ.to_s.downcase
936
- valid = type == "floor" || type == "wall" || type == "roofceiling"
937
- return invalid("surface type", mth, 5, DBG, false) unless valid
2247
+ cl2 = OpenStudio::Model::PlanarSurfaceGroup
2248
+ res = { t: nil, r: nil }
2249
+ return invalid("group", mth, 2, DBG, res) unless group.respond_to?(NS)
938
2250
 
939
- constructions = nil
2251
+ id = group.nameString
2252
+ mdl = group.model
2253
+ return mismatch(id, group, cl2, mth, DBG, res) unless group.is_a?(cl2)
940
2254
 
941
- if gr
942
- unless set.defaultGroundContactSurfaceConstructions.empty?
943
- constructions = set.defaultGroundContactSurfaceConstructions.get
944
- end
945
- elsif ex
946
- unless set.defaultExteriorSurfaceConstructions.empty?
947
- constructions = set.defaultExteriorSurfaceConstructions.get
948
- end
949
- else
950
- unless set.defaultInteriorSurfaceConstructions.empty?
951
- constructions = set.defaultInteriorSurfaceConstructions.get
952
- end
953
- end
2255
+ res[:t] = group.siteTransformation
2256
+ res[:r] = group.directionofRelativeNorth + mdl.getBuilding.northAxis
954
2257
 
955
- return false unless constructions
2258
+ res
2259
+ end
956
2260
 
957
- case type
958
- when "roofceiling"
959
- unless constructions.roofCeilingConstruction.empty?
960
- construction = constructions.roofCeilingConstruction.get
961
- return true if construction == bse
962
- end
963
- when "floor"
964
- unless constructions.floorConstruction.empty?
965
- construction = constructions.floorConstruction.get
966
- return true if construction == bse
967
- end
968
- else
969
- unless constructions.wallConstruction.empty?
970
- construction = constructions.wallConstruction.get
971
- return true if construction == bse
972
- end
973
- end
2261
+ ##
2262
+ # Returns true if 2 OpenStudio 3D points are nearly equal
2263
+ #
2264
+ # @param p1 [OpenStudio::Point3d] 1st 3D point
2265
+ # @param p2 [OpenStudio::Point3d] 2nd 3D point
2266
+ #
2267
+ # @return [Bool] whether equal points (within TOL)
2268
+ # @return [false] if invalid input (see logs)
2269
+ def same?(p1 = nil, p2 = nil)
2270
+ mth = "OSut::#{__callee__}"
2271
+ cl = OpenStudio::Point3d
2272
+ return mismatch("point 1", p1, cl, mth, DBG, false) unless p1.is_a?(cl)
2273
+ return mismatch("point 2", p2, cl, mth, DBG, false) unless p2.is_a?(cl)
974
2274
 
975
- false
2275
+ # OpenStudio.isAlmostEqual3dPt(p1, p2, TOL) # ... from v350 onwards.
2276
+ (p1.x-p2.x).abs < TOL && (p1.y-p2.y).abs < TOL && (p1.z-p2.z).abs < TOL
976
2277
  end
977
2278
 
978
2279
  ##
979
- # Return a surface's default construction set.
2280
+ # Returns true if a line segment is along the X-axis.
980
2281
  #
981
- # @param model [OpenStudio::Model::Model] a model
982
- # @param s [OpenStudio::Model::Surface] a surface
2282
+ # @param p1 [OpenStudio::Point3d] 1st 3D point of a line segment
2283
+ # @param p2 [OpenStudio::Point3d] 2nd 3D point of a line segment
2284
+ # @param strict [Bool] whether segment shouldn't hold Y- or Z-axis components
983
2285
  #
984
- # @return [OpenStudio::Model::DefaultConstructionSet] default set
985
- # @return [NilClass] if invalid input
986
- def defaultConstructionSet(model = nil, s = nil)
2286
+ # @return [Bool] whether along the X-axis
2287
+ # @return [false] if invalid input (see logs)
2288
+ def xx?(p1 = nil, p2 = nil, strict = true)
987
2289
  mth = "OSut::#{__callee__}"
988
- cl1 = OpenStudio::Model::Model
989
- cl2 = OpenStudio::Model::Surface
990
-
991
- return mismatch("model", model, cl1, mth) unless model.is_a?(cl1)
992
- return invalid("s", mth, 2) unless s.respond_to?(NS)
2290
+ cl = OpenStudio::Point3d
2291
+ strict = true unless [true, false].include?(strict)
2292
+ return mismatch("point 1", p1, cl, mth, DBG, false) unless p1.is_a?(cl)
2293
+ return mismatch("point 2", p2, cl, mth, DBG, false) unless p2.is_a?(cl)
2294
+ return false if (p1.y - p2.y).abs > TOL && strict
2295
+ return false if (p1.z - p2.z).abs > TOL && strict
2296
+
2297
+ (p1.x - p2.x).abs > TOL
2298
+ end
993
2299
 
994
- id = s.nameString
995
- return mismatch(id, s, cl2, mth) unless s.is_a?(cl2)
2300
+ ##
2301
+ # Returns true if a line segment is along the Y-axis.
2302
+ #
2303
+ # @param p1 [OpenStudio::Point3d] 1st 3D point of a line segment
2304
+ # @param p2 [OpenStudio::Point3d] 2nd 3D point of a line segment
2305
+ # @param strict [Bool] whether segment shouldn't hold X- or Z-axis components
2306
+ #
2307
+ # @return [Bool] whether along the Y-axis
2308
+ # @return [false] if invalid input (see logs)
2309
+ def yy?(p1 = nil, p2 = nil, strict = true)
2310
+ mth = "OSut::#{__callee__}"
2311
+ cl = OpenStudio::Point3d
2312
+ strict = true unless [true, false].include?(strict)
2313
+ return mismatch("point 1", p1, cl, mth, DBG, false) unless p1.is_a?(cl)
2314
+ return mismatch("point 2", p2, cl, mth, DBG, false) unless p2.is_a?(cl)
2315
+ return false if (p1.x - p2.x).abs > TOL && strict
2316
+ return false if (p1.z - p2.z).abs > TOL && strict
2317
+
2318
+ (p1.y - p2.y).abs > TOL
2319
+ end
996
2320
 
997
- ok = s.isConstructionDefaulted
998
- log(ERR, "'#{id}' construction not defaulted (#{mth})") unless ok
999
- return nil unless ok
1000
- return empty("'#{id}' construction", mth, ERR) if s.construction.empty?
2321
+ ##
2322
+ # Returns true if a line segment is along the Z-axis.
2323
+ #
2324
+ # @param p1 [OpenStudio::Point3d] 1st 3D point of a line segment
2325
+ # @param p2 [OpenStudio::Point3d] 2nd 3D point of a line segment
2326
+ # @param strict [Bool] whether segment shouldn't hold X- or Y-axis components
2327
+ #
2328
+ # @return [Bool] whether along the Z-axis
2329
+ # @return [false] if invalid input (see logs)
2330
+ def zz?(p1 = nil, p2 = nil, strict = true)
2331
+ mth = "OSut::#{__callee__}"
2332
+ cl = OpenStudio::Point3d
2333
+ strict = true unless [true, false].include?(strict)
2334
+ return mismatch("point 1", p1, cl, mth, DBG, false) unless p1.is_a?(cl)
2335
+ return mismatch("point 2", p2, cl, mth, DBG, false) unless p2.is_a?(cl)
2336
+ return false if (p1.x - p2.x).abs > TOL && strict
2337
+ return false if (p1.y - p2.y).abs > TOL && strict
2338
+
2339
+ (p1.z - p2.z).abs > TOL
2340
+ end
1001
2341
 
1002
- base = s.construction.get
1003
- return empty("'#{id}' space", mth, ERR) if s.space.empty?
2342
+ ##
2343
+ # Returns a scalar product of an OpenStudio Vector3d.
2344
+ #
2345
+ # @param v [OpenStudio::Vector3d] a vector
2346
+ # @param m [#to_f] a scalar
2347
+ #
2348
+ # @return [OpenStudio::Vector3d] scaled points (see logs if empty)
2349
+ def scalar(v = OpenStudio::Vector3d.new, m = 0)
2350
+ mth = "OSut::#{__callee__}"
2351
+ cl = OpenStudio::Vector3d
2352
+ ok = m.respond_to?(:to_f)
2353
+ return mismatch("vector", v, cl, mth, DBG, v) unless v.is_a?(cl)
2354
+ return mismatch("m", m, Numeric, mth, DBG, v) unless ok
1004
2355
 
1005
- space = s.space.get
1006
- type = s.surfaceType
1007
- ground = false
1008
- exterior = false
2356
+ m = m.to_f
2357
+ OpenStudio::Vector3d.new(m * v.x, m * v.y, m * v.z)
2358
+ end
1009
2359
 
1010
- if s.isGroundSurface
1011
- ground = true
1012
- elsif s.outsideBoundaryCondition.downcase == "outdoors"
1013
- exterior = true
1014
- end
2360
+ ##
2361
+ # Returns OpenStudio 3D points as an OpenStudio point vector, validating
2362
+ # points in the process (if Array).
2363
+ #
2364
+ # @param pts [Set<OpenStudio::Point3d>] OpenStudio 3D points
2365
+ #
2366
+ # @return [OpenStudio::Point3dVector] 3D vector (see logs if empty)
2367
+ def to_p3Dv(pts = nil)
2368
+ mth = "OSut::#{__callee__}"
2369
+ cl1 = Array
2370
+ cl2 = OpenStudio::Point3dVector
2371
+ cl3 = OpenStudio::Model::PlanarSurface
2372
+ cl4 = OpenStudio::Point3d
2373
+ v = OpenStudio::Point3dVector.new
2374
+ return pts if pts.is_a?(cl2)
2375
+ return pts.vertices if pts.is_a?(cl3)
2376
+ return mismatch("points", pts, cl1, mth, DBG, v) unless pts.is_a?(cl1)
1015
2377
 
1016
- unless space.defaultConstructionSet.empty?
1017
- set = space.defaultConstructionSet.get
1018
- return set if holdsConstruction?(set, base, ground, exterior, type)
2378
+ pts.each do |pt|
2379
+ return mismatch("point", pt, cl4, mth, DBG, v) unless pt.is_a?(cl4)
1019
2380
  end
1020
2381
 
1021
- unless space.spaceType.empty?
1022
- spacetype = space.spaceType.get
2382
+ pts.each { |pt| v << OpenStudio::Point3d.new(pt.x, pt.y, pt.z) }
1023
2383
 
1024
- unless spacetype.defaultConstructionSet.empty?
1025
- set = spacetype.defaultConstructionSet.get
1026
- return set if holdsConstruction?(set, base, ground, exterior, type)
1027
- end
1028
- end
2384
+ v
2385
+ end
1029
2386
 
1030
- unless space.buildingStory.empty?
1031
- story = space.buildingStory.get
2387
+ ##
2388
+ # Returns true if an OpenStudio 3D point is part of a set of 3D points.
2389
+ #
2390
+ # @param pts [Set<OpenStudio::Point3dVector>] 3d points
2391
+ # @param p1 [OpenStudio::Point3d] a 3D point
2392
+ #
2393
+ # @return [Bool] whether part of a set of 3D points
2394
+ # @return [false] if invalid input (see logs)
2395
+ def holds?(pts = nil, p1 = nil)
2396
+ mth = "OSut::#{__callee__}"
2397
+ pts = to_p3Dv(pts)
2398
+ cl = OpenStudio::Point3d
2399
+ return mismatch("point", p1, cl, mth, DBG, false) unless p1.is_a?(cl)
1032
2400
 
1033
- unless story.defaultConstructionSet.empty?
1034
- set = story.defaultConstructionSet.get
1035
- return set if holdsConstruction?(set, base, ground, exterior, type)
1036
- end
1037
- end
2401
+ pts.each { |pt| return true if same?(p1, pt) }
1038
2402
 
1039
- building = model.getBuilding
2403
+ false
2404
+ end
1040
2405
 
1041
- unless building.defaultConstructionSet.empty?
1042
- set = building.defaultConstructionSet.get
1043
- return set if holdsConstruction?(set, base, ground, exterior, type)
2406
+ ##
2407
+ # Flattens OpenStudio 3D points vs X, Y or Z axes.
2408
+ #
2409
+ # @param pts [Set<OpenStudio::Point3d>] 3D points
2410
+ # @param axs [Symbol] :x, :y or :z axis
2411
+ # @param val [#to_f] axis value
2412
+ #
2413
+ # @return [OpenStudio::Point3dVector] flattened points (see logs if empty)
2414
+ def flatten(pts = nil, axs = :z, val = 0)
2415
+ mth = "OSut::#{__callee__}"
2416
+ pts = to_p3Dv(pts)
2417
+ v = OpenStudio::Point3dVector.new
2418
+ ok1 = val.respond_to?(:to_f)
2419
+ ok2 = [:x, :y, :z].include?(axs)
2420
+ return mismatch("val", val, Numeric, mth, DBG, v) unless ok1
2421
+ return invalid("axis (XYZ?)", mth, 2, DBG, v) unless ok2
2422
+
2423
+ val = val.to_f
2424
+
2425
+ case axs
2426
+ when :x
2427
+ pts.each { |pt| v << OpenStudio::Point3d.new(val, pt.y, pt.z) }
2428
+ when :y
2429
+ pts.each { |pt| v << OpenStudio::Point3d.new(pt.x, val, pt.z) }
2430
+ else
2431
+ pts.each { |pt| v << OpenStudio::Point3d.new(pt.x, pt.y, val) }
1044
2432
  end
1045
2433
 
1046
- nil
2434
+ v
1047
2435
  end
1048
2436
 
1049
2437
  ##
1050
- # Validate if every material in a layered construction is standard & opaque.
2438
+ # Returns true if OpenStudio 3D points share X, Y or Z coordinates.
1051
2439
  #
1052
- # @param lc [OpenStudio::LayeredConstruction] a layered construction
2440
+ # @param pts [Set<OpenStudio::Point3d>] OpenStudio 3D points
2441
+ # @param axs [Symbol] if potentially along :x, :y or :z axis
2442
+ # @param val [Numeric] axis value
1053
2443
  #
1054
- # @return [Bool] true if all layers are valid
1055
- # @return [Bool] false if invalid input
1056
- def standardOpaqueLayers?(lc = nil)
2444
+ # @return [Bool] if points share X, Y or Z coordinates
2445
+ # @return [false] if invalid input (see logs)
2446
+ def xyz?(pts = nil, axs = :z, val = 0)
1057
2447
  mth = "OSut::#{__callee__}"
1058
- cl = OpenStudio::Model::LayeredConstruction
1059
-
1060
- return invalid("lc", mth, 1, DBG, false) unless lc.respond_to?(NS)
1061
- return mismatch(lc.nameString, lc, cl, mth, DBG, false) unless lc.is_a?(cl)
1062
-
1063
- lc.layers.each { |m| return false if m.to_StandardOpaqueMaterial.empty? }
2448
+ pts = to_p3Dv(pts)
2449
+ ok1 = val.respond_to?(:to_f)
2450
+ ok2 = [:x, :y, :z].include?(axs)
2451
+ return false if pts.empty?
2452
+ return mismatch("val", val, Numeric, mth, DBG, false) unless ok1
2453
+ return invalid("axis (XYZ?)", mth, 2, DBG, false) unless ok2
2454
+
2455
+ val = val.to_f
2456
+
2457
+ case axs
2458
+ when :x
2459
+ pts.each { |pt| return false if (pt.x - val).abs > TOL }
2460
+ when :y
2461
+ pts.each { |pt| return false if (pt.y - val).abs > TOL }
2462
+ else
2463
+ pts.each { |pt| return false if (pt.z - val).abs > TOL }
2464
+ end
1064
2465
 
1065
2466
  true
1066
2467
  end
1067
2468
 
1068
2469
  ##
1069
- # Total (standard opaque) layered construction thickness (in m).
2470
+ # Returns next sequential point in an OpenStudio 3D point vector.
1070
2471
  #
1071
- # @param lc [OpenStudio::LayeredConstruction] a layered construction
2472
+ # @param pts [OpenStudio::Point3dVector] 3D points
2473
+ # @param pt [OpenStudio::Point3d] a given 3D point
1072
2474
  #
1073
- # @return [Float] total layered construction thickness
1074
- # @return [Float] 0 if invalid input
1075
- def thickness(lc = nil)
2475
+ # @return [OpenStudio::Point3d] the next sequential point
2476
+ # @return [nil] if invalid input (see logs)
2477
+ def next(pts = nil, pt = nil)
1076
2478
  mth = "OSut::#{__callee__}"
1077
- cl = OpenStudio::Model::LayeredConstruction
2479
+ pts = to_p3Dv(pts)
2480
+ cl = OpenStudio::Point3d
2481
+ return mismatch("point", pt, cl, mth) unless pt.is_a?(cl)
2482
+ return invalid("points (2+)", mth, 1, WRN) if pts.size < 2
1078
2483
 
1079
- return invalid("lc", mth, 1, DBG, 0.0) unless lc.respond_to?(NS)
2484
+ pair = pts.each_cons(2).find { |p1, _| same?(p1, pt) }
1080
2485
 
1081
- id = lc.nameString
1082
- return mismatch(id, lc, cl, mth, DBG, 0.0) unless lc.is_a?(cl)
1083
-
1084
- ok = standardOpaqueLayers?(lc)
1085
- log(ERR, "'#{id}' holds non-StandardOpaqueMaterial(s) (#{mth})") unless ok
1086
- return 0.0 unless ok
1087
-
1088
- thickness = 0.0
1089
- lc.layers.each { |m| thickness += m.thickness }
1090
-
1091
- thickness
2486
+ pair.nil? ? pts.first : pair.last
1092
2487
  end
1093
2488
 
1094
2489
  ##
1095
- # Return total air film resistance for fenestration.
2490
+ # Returns unique OpenStudio 3D points from an OpenStudio 3D point vector.
1096
2491
  #
1097
- # @param usi [Float] a fenestrated construction's U-factor (W/m2•K)
2492
+ # @param pts [Set<OpenStudio::Point3d] 3D points
2493
+ # @param n [#to_i] requested number of unique points (0 returns all)
1098
2494
  #
1099
- # @return [Float] total air film resistance in m2•K/W (0.1216 if errors)
1100
- def glazingAirFilmRSi(usi = 5.85)
1101
- # The sum of thermal resistances of calculated exterior and interior film
1102
- # coefficients under standard winter conditions are taken from:
1103
- #
1104
- # https://bigladdersoftware.com/epx/docs/9-6/engineering-reference/
1105
- # window-calculation-module.html#simple-window-model
1106
- #
1107
- # These remain acceptable approximations for flat windows, yet likely
1108
- # unsuitable for subsurfaces with curved or projecting shapes like domed
1109
- # skylights. The solution here is considered an adequate fix for reporting,
1110
- # awaiting eventual OpenStudio (and EnergyPlus) upgrades to report NFRC 100
1111
- # (or ISO) air film resistances under standard winter conditions.
1112
- #
1113
- # For U-factors above 8.0 W/m2•K (or invalid input), the function returns
1114
- # 0.1216 m2•K/W, which corresponds to a construction with a single glass
1115
- # layer thickness of 2mm & k = ~0.6 W/m.K.
1116
- #
1117
- # The EnergyPlus Engineering calculations were designed for vertical windows
1118
- # - not horizontal, slanted or domed surfaces - use with caution.
2495
+ # @return [OpenStudio::Point3dVector] unique points (see logs if empty)
2496
+ def getUniques(pts = nil, n = 0)
1119
2497
  mth = "OSut::#{__callee__}"
1120
- cl = Numeric
2498
+ pts = to_p3Dv(pts)
2499
+ ok = n.respond_to?(:to_i)
2500
+ v = OpenStudio::Point3dVector.new
2501
+ return v if pts.empty?
2502
+ return mismatch("n unique points", n, Integer, mth, DBG, v) unless ok
1121
2503
 
1122
- return mismatch("usi", usi, cl, mth, DBG, 0.1216) unless usi.is_a?(cl)
1123
- return invalid("usi", mth, 1, WRN, 0.1216) if usi > 8.0
1124
- return negative("usi", mth, WRN, 0.1216) if usi < 0
1125
- return zero("usi", mth, WRN, 0.1216) if usi.abs < TOL
2504
+ pts.each { |pt| v << pt unless holds?(v, pt) }
1126
2505
 
1127
- rsi = 1 / (0.025342 * usi + 29.163853) # exterior film, next interior film
2506
+ n = n.to_i
2507
+ n = 0 unless n.abs < v.size
2508
+ v = v[0..n] if n > 0
2509
+ v = v[n..-1] if n < 0
1128
2510
 
1129
- return rsi + 1 / (0.359073 * Math.log(usi) + 6.949915) if usi < 5.85
1130
- return rsi + 1 / (1.788041 * usi - 2.886625)
2511
+ v
1131
2512
  end
1132
2513
 
1133
2514
  ##
1134
- # Return a construction's 'standard calc' thermal resistance (with air films).
2515
+ # Returns sequential non-collinear points in an OpenStudio 3D point vector.
1135
2516
  #
1136
- # @param lc [OpenStudio::Model::LayeredConstruction] a layered construction
1137
- # @param film [Float] thermal resistance of surface air films (m2•K/W)
1138
- # @param t [Float] gas temperature (°C) (optional)
2517
+ # @param pts [Set<OpenStudio::Point3d] 3D points
2518
+ # @param n [#to_i] requested number of non-collinears (0 returns all)
1139
2519
  #
1140
- # @return [Float] calculated RSi at standard conditions (0 if error)
1141
- def rsi(lc = nil, film = 0.0, t = 0.0)
1142
- # This is adapted from BTAP's Material Module's "get_conductance" (P. Lopez)
1143
- #
1144
- # https://github.com/NREL/OpenStudio-Prototype-Buildings/blob/
1145
- # c3d5021d8b7aef43e560544699fb5c559e6b721d/lib/btap/measures/
1146
- # btap_equest_converter/envelope.rb#L122
2520
+ # @return [OpenStudio::Point3dVector] non-collinears (see logs if empty)
2521
+ def getNonCollinears(pts = nil, n = 0)
1147
2522
  mth = "OSut::#{__callee__}"
1148
- cl1 = OpenStudio::Model::LayeredConstruction
1149
- cl2 = Numeric
1150
-
1151
- return invalid("lc", mth, 1, DBG, 0.0) unless lc.respond_to?(NS)
1152
-
1153
- id = lc.nameString
1154
-
1155
- return mismatch(id, lc, cl1, mth, DBG, 0.0) unless lc.is_a?(cl1)
1156
- return mismatch("film", film, cl2, mth, DBG, 0.0) unless film.is_a?(cl2)
1157
- return mismatch("temp K", t, cl2, mth, DBG, 0.0) unless t.is_a?(cl2)
1158
-
1159
- t += 273.0 # °C to K
1160
- return negative("temp K", mth, DBG, 0.0) if t < 0
1161
- return negative("film", mth, DBG, 0.0) if film < 0
1162
-
1163
- rsi = film
1164
-
1165
- lc.layers.each do |m|
1166
- # Fenestration materials first (ignoring shades, screens, etc.)
1167
- empty = m.to_SimpleGlazing.empty?
1168
- return 1 / m.to_SimpleGlazing.get.uFactor unless empty
1169
-
1170
- empty = m.to_StandardGlazing.empty?
1171
- rsi += m.to_StandardGlazing.get.thermalResistance unless empty
1172
- empty = m.to_RefractionExtinctionGlazing.empty?
1173
- rsi += m.to_RefractionExtinctionGlazing.get.thermalResistance unless empty
1174
- empty = m.to_Gas.empty?
1175
- rsi += m.to_Gas.get.getThermalResistance(t) unless empty
1176
- empty = m.to_GasMixture.empty?
1177
- rsi += m.to_GasMixture.get.getThermalResistance(t) unless empty
2523
+ pts = getUniques(pts)
2524
+ ok = n.respond_to?(:to_i)
2525
+ v = OpenStudio::Point3dVector.new
2526
+ a = []
2527
+ return pts if pts.size < 2
2528
+ return mismatch("n non-collinears", n, Integer, mth, DBG, v) unless ok
2529
+
2530
+ # Evaluate cross product of vectors of 3x sequential points.
2531
+ pts.each_with_index do |p2, i2|
2532
+ i1 = i2 - 1
2533
+ i3 = i2 + 1
2534
+ i3 = 0 if i3 == pts.size
2535
+ p1 = pts[i1]
2536
+ p3 = pts[i3]
2537
+ v13 = p3 - p1
2538
+ v12 = p2 - p1
2539
+ next if v12.cross(v13).length < TOL
2540
+
2541
+ a << p2
2542
+ end
1178
2543
 
1179
- # Opaque materials next.
1180
- empty = m.to_StandardOpaqueMaterial.empty?
1181
- rsi += m.to_StandardOpaqueMaterial.get.thermalResistance unless empty
1182
- empty = m.to_MasslessOpaqueMaterial.empty?
1183
- rsi += m.to_MasslessOpaqueMaterial.get.thermalResistance unless empty
1184
- empty = m.to_RoofVegetation.empty?
1185
- rsi += m.to_RoofVegetation.get.thermalResistance unless empty
1186
- empty = m.to_AirGap.empty?
1187
- rsi += m.to_AirGap.get.thermalResistance unless empty
2544
+ if holds?(a, pts[0])
2545
+ a = a.rotate(-1) unless same?(a[0], pts[0])
1188
2546
  end
1189
2547
 
1190
- rsi
2548
+ n = n.to_i
2549
+ n = 0 unless n.abs < pts.size
2550
+ a = a[0..n] if n > 0
2551
+ a = a[n..-1] if n < 0
2552
+
2553
+ to_p3Dv(a)
1191
2554
  end
1192
2555
 
1193
2556
  ##
1194
- # Identify a layered construction's (opaque) insulating layer. The method
1195
- # returns a 3-keyed hash ... :index (insulating layer index within layered
1196
- # construction), :type (standard: or massless: material type), and
1197
- # :r (material thermal resistance in m2•K/W).
2557
+ # Returns paired sequential points as (non-zero length) line segments. If the
2558
+ # set strictly holds 2x unique points, a single segment is returned.
2559
+ # Otherwise, the returned number of segments equals the number of unique
2560
+ # points. If non-collinearity is requested, then the number of returned
2561
+ # segments equals the number of non-colliear points.
1198
2562
  #
1199
- # @param lc [OpenStudio::Model::LayeredConstruction] a layered construction
2563
+ # @param pts [Set<OpenStudio::Point3d>] 3D points
2564
+ # @param co [Bool] whether to keep collinear points
1200
2565
  #
1201
- # @return [Hash] index: (Integer), type: (:standard or :massless), r: (Float)
1202
- # @return [Hash] index: nil, type: nil, r: 0 (if invalid input)
1203
- def insulatingLayer(lc = nil)
2566
+ # @return [OpenStudio::Point3dVectorVector] line segments (see logs if empty)
2567
+ def getSegments(pts = nil, co = false)
1204
2568
  mth = "OSut::#{__callee__}"
1205
- cl = OpenStudio::Model::LayeredConstruction
1206
- res = { index: nil, type: nil, r: 0.0 }
1207
- i = 0 # iterator
1208
-
1209
- return invalid("lc", mth, 1, DBG, res) unless lc.respond_to?(NS)
1210
-
1211
- id = lc.nameString
1212
- return mismatch(id, lc, cl1, mth, DBG, res) unless lc.is_a?(cl)
1213
-
1214
- lc.layers.each do |m|
1215
- unless m.to_MasslessOpaqueMaterial.empty?
1216
- m = m.to_MasslessOpaqueMaterial.get
1217
-
1218
- if m.thermalResistance < 0.001 || m.thermalResistance < res[:r]
1219
- i += 1
1220
- next
1221
- else
1222
- res[:r ] = m.thermalResistance
1223
- res[:index] = i
1224
- res[:type ] = :massless
1225
- end
1226
- end
1227
-
1228
- unless m.to_StandardOpaqueMaterial.empty?
1229
- m = m.to_StandardOpaqueMaterial.get
1230
- k = m.thermalConductivity
1231
- d = m.thickness
1232
-
1233
- if d < 0.003 || k > 3.0 || d / k < res[:r]
1234
- i += 1
1235
- next
1236
- else
1237
- res[:r ] = d / k
1238
- res[:index] = i
1239
- res[:type ] = :standard
1240
- end
1241
- end
1242
-
1243
- i += 1
2569
+ vv = OpenStudio::Point3dVectorVector.new
2570
+ co = false unless [true, false].include?(co)
2571
+ pts = getNonCollinears(pts) unless co
2572
+ pts = getUniques(pts) if co
2573
+ return vv if pts.size < 2
2574
+
2575
+ pts.each_with_index do |p1, i1|
2576
+ i2 = i1 + 1
2577
+ i2 = 0 if i2 == pts.size
2578
+ p2 = pts[i2]
2579
+
2580
+ line = OpenStudio::Point3dVector.new
2581
+ line << p1
2582
+ line << p2
2583
+ vv << line
2584
+ break if pts.size == 2
1244
2585
  end
1245
2586
 
1246
- res
2587
+ vv
1247
2588
  end
1248
2589
 
1249
2590
  ##
1250
- # Return OpenStudio site/space transformation & rotation angle [0,2PI) rads.
2591
+ # Returns points as (non-zero length) 'triads', i.e. 3x sequential points.
2592
+ # If the set holds less than 3x unique points, an empty triad is
2593
+ # returned. Otherwise, the returned number of triads equals the number of
2594
+ # unique points. If non-collinearity is requested, then the number of
2595
+ # returned triads equals the number of non-collinear points.
1251
2596
  #
1252
- # @param model [OpenStudio::Model::Model] a model
1253
- # @param group [OpenStudio::Model::PlanarSurfaceGroup] a group
2597
+ # @param pts [OpenStudio::Point3dVector] 3D points
2598
+ # @param co [Bool] whether to keep collinear points
1254
2599
  #
1255
- # @return [Hash] t: (OpenStudio::Transformation), r: Float
1256
- # @return [Hash] t: nil, r: nil (if invalid input)
1257
- def transforms(model = nil, group = nil)
2600
+ # @return [OpenStudio::Point3dVectorVector] triads (see logs if empty)
2601
+ def getTriads(pts = nil, co = false)
1258
2602
  mth = "OSut::#{__callee__}"
1259
- cl1 = OpenStudio::Model::Model
1260
- cl2 = OpenStudio::Model::PlanarSurfaceGroup
1261
- res = { t: nil, r: nil }
1262
-
1263
- return mismatch("model", model, cl1, mth, DBG, res) unless model.is_a?(cl1)
1264
- return invalid("group", mth, 2, DBG, res) unless group.respond_to?(NS)
1265
-
1266
- id = group.nameString
1267
- return mismatch(id, group, cl2, mth, DBG, res) unless group.is_a?(cl2)
1268
-
1269
- res[:t] = group.siteTransformation
1270
- res[:r] = group.directionofRelativeNorth + model.getBuilding.northAxis
2603
+ vv = OpenStudio::Point3dVectorVector.new
2604
+ co = false unless [true, false].include?(co)
2605
+ pts = getNonCollinears(pts) unless co
2606
+ pts = getUniques(pts) if co
2607
+ return vv if pts.size < 2
2608
+
2609
+ pts.each_with_index do |p1, i1|
2610
+ i2 = i1 + 1
2611
+ i2 = 0 if i2 == pts.size
2612
+ i3 = i2 + 1
2613
+ i3 = 0 if i3 == pts.size
2614
+ p2 = pts[i2]
2615
+ p3 = pts[i3]
2616
+
2617
+ tri = OpenStudio::Point3dVector.new
2618
+ tri << p1
2619
+ tri << p2
2620
+ tri << p3
2621
+ vv << tri
2622
+ end
1271
2623
 
1272
- res
2624
+ vv
1273
2625
  end
1274
2626
 
1275
2627
  ##
1276
- # Return a scalar product of an OpenStudio Vector3d.
2628
+ # Determines if pre-'aligned' OpenStudio 3D points are listed clockwise.
1277
2629
  #
1278
- # @param v [OpenStudio::Vector3d] a vector
1279
- # @param m [Float] a scalar
2630
+ # @param pts [OpenStudio::Point3dVector] 3D points
1280
2631
  #
1281
- # @return [OpenStudio::Vector3d] modified vector
1282
- # @return [OpenStudio::Vector3d] provided (or empty) vector if invalid input
1283
- def scalar(v = OpenStudio::Vector3d.new(0,0,0), m = 0)
2632
+ # @return [Bool] whether sequence is clockwise
2633
+ # @return [false] if invalid input (see logs)
2634
+ def clockwise?(pts = nil)
1284
2635
  mth = "OSut::#{__callee__}"
1285
- cl1 = OpenStudio::Vector3d
1286
- cl2 = Numeric
2636
+ pts = to_p3Dv(pts)
2637
+ n = false
2638
+ return invalid("points (3+)", mth, 1, DBG, n) if pts.size < 3
2639
+ return invalid("points (aligned)", mth, 1, DBG, n) unless xyz?(pts, :z, 0)
1287
2640
 
1288
- return mismatch("vector", v, cl1, mth, DBG, v) unless v.is_a?(cl1)
1289
- return mismatch("x", v.x, cl2, mth, DBG, v) unless v.x.respond_to?(:to_f)
1290
- return mismatch("y", v.y, cl2, mth, DBG, v) unless v.y.respond_to?(:to_f)
1291
- return mismatch("z", v.z, cl2, mth, DBG, v) unless v.z.respond_to?(:to_f)
1292
- return mismatch("m", m, cl2, mth, DBG, v) unless m.respond_to?(:to_f)
1293
-
1294
- OpenStudio::Vector3d.new(m * v.x, m * v.y, m * v.z)
2641
+ OpenStudio.pointInPolygon(pts.first, pts, TOL)
1295
2642
  end
1296
2643
 
1297
2644
  ##
1298
- # Flatten OpenStudio 3D points vs Z-axis (Z=0).
2645
+ # Returns 'aligned' OpenStudio 3D points conforming to Openstudio's
2646
+ # counterclockwise UpperLeftCorner (ULC) convention.
1299
2647
  #
1300
- # @param pts [Array] an OpenStudio Point3D array/vector
2648
+ # @param pts [Set<OpenStudio::Point3d>] aligned 3D points
1301
2649
  #
1302
- # @return [Array] flattened OpenStudio 3D points
1303
- def flatZ(pts = nil)
2650
+ # @return [OpenStudio::Point3dVector] ULC points (see logs if empty)
2651
+ def ulc(pts = nil)
1304
2652
  mth = "OSut::#{__callee__}"
1305
- cl1 = OpenStudio::Point3dVector
1306
- cl2 = OpenStudio::Point3d
2653
+ pts = to_p3Dv(pts)
1307
2654
  v = OpenStudio::Point3dVector.new
2655
+ p0 = OpenStudio::Point3d.new(0,0,0)
2656
+ i0 = nil
1308
2657
 
1309
- valid = pts.is_a?(cl1) || pts.is_a?(Array)
1310
- return mismatch("points", pts, cl1, mth, DBG, v) unless valid
2658
+ return invalid("points (3+)", mth, 1, DBG, v) if pts.size < 3
2659
+ return invalid("points (aligned)", mth, 1, DBG, v) unless xyz?(pts, :z, 0)
1311
2660
 
1312
- pts.each { |pt| mismatch("pt", pt, cl2, mth, ERR, v) unless pt.is_a?(cl2) }
1313
- pts.each { |pt| v << OpenStudio::Point3d.new(pt.x, pt.y, 0) }
2661
+ # Ensure counterclockwise sequence.
2662
+ pts = pts.to_a
2663
+ pts = pts.reverse if clockwise?(pts)
1314
2664
 
1315
- v
2665
+ # Fetch index of candidate (0,0,0) point (i == 1, in most cases). Resort
2666
+ # to last X == 0 point. Leave as is if failed attempts.
2667
+ i0 = pts.index { |pt| same?(pt, p0) }
2668
+ i0 = pts.rindex { |pt| pt.x.abs < TOL } if i0.nil?
2669
+
2670
+ unless i0.nil?
2671
+ i = pts.size - 1
2672
+ i = i0 - 1 unless i0 == 0
2673
+ pts = pts.rotate(i)
2674
+ end
2675
+
2676
+ to_p3Dv(pts)
1316
2677
  end
1317
2678
 
1318
2679
  ##
1319
- # Validate whether 1st OpenStudio convex polygon fits in 2nd convex polygon.
1320
- #
1321
- # @param p1 [OpenStudio::Point3dVector] or Point3D array of polygon #1
1322
- # @param p2 [OpenStudio::Point3dVector] or Point3D array of polygon #2
1323
- # @param id1 [String] polygon #1 identifier (optional)
1324
- # @param id2 [String] polygon #2 identifier (optional)
1325
- #
1326
- # @return [Bool] true if 1st polygon fits entirely within the 2nd polygon
1327
- # @return [Bool] false if invalid input
1328
- def fits?(p1 = nil, p2 = nil, id1 = "", id2 = "")
2680
+ # Returns an OpenStudio 3D point vector as basis for a valid OpenStudio 3D
2681
+ # polygon. In addition to basic OpenStudio polygon tests (e.g. all points
2682
+ # sharing the same 3D plane, non-self-intersecting), the method can
2683
+ # optionally check for convexity, or ensure uniqueness and/or collinearity.
2684
+ # Returned vector can also be 'aligned', as well as in UpperLeftCorner (ULC)
2685
+ # counterclockwise sequence, or in clockwise sequence.
2686
+ #
2687
+ # @param pts [Set<OpenStudio::Point3d>] 3D points
2688
+ # @param vx [Bool] whether to check for convexity
2689
+ # @param uq [Bool] whether to ensure uniqueness
2690
+ # @param co [Bool] whether to ensure non-collinearity
2691
+ # @param tt [Bool, OpenStudio::Transformation] whether to 'align'
2692
+ # @param sq [:no, :ulc, :cw] unaltered, ULC or clockwise sequence
2693
+ #
2694
+ # @return [OpenStudio::Point3dVector] 3D points (see logs if empty)
2695
+ def poly(pts = nil, vx = false, uq = false, co = true, tt = false, sq = :no)
1329
2696
  mth = "OSut::#{__callee__}"
1330
- cl1 = OpenStudio::Point3dVector
1331
- cl2 = OpenStudio::Point3d
1332
- a = false
2697
+ pts = to_p3Dv(pts)
2698
+ cl = OpenStudio::Transformation
2699
+ v = OpenStudio::Point3dVector.new
2700
+ vx = false unless [true, false].include?(vx)
2701
+ uq = false unless [true, false].include?(uq)
2702
+ co = true unless [true, false].include?(co)
1333
2703
 
1334
- return invalid("id1", mth, 3, DBG, a) unless id1.respond_to?(:to_s)
1335
- return invalid("id2", mth, 4, DBG, a) unless id2.respond_to?(:to_s)
2704
+ # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- #
2705
+ # Exit if mismatched/invalid arguments.
2706
+ ok1 = tt == true || tt == false || tt.is_a?(cl)
2707
+ ok2 = sq == :no || sq == :ulc || sq == :cw
2708
+ return invalid("transformation", mth, 5, DBG, v) unless ok1
2709
+ return invalid("sequence", mth, 6, DBG, v) unless ok2
1336
2710
 
1337
- i1 = id1.to_s
1338
- i2 = id2.to_s
1339
- i1 = "poly1" if i1.empty?
1340
- i2 = "poly2" if i2.empty?
2711
+ # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- #
2712
+ # Basic tests:
2713
+ p3 = getNonCollinears(pts, 3)
2714
+ return empty("polygon", mth, ERR, v) if p3.size < 3
1341
2715
 
1342
- valid1 = p1.is_a?(cl1) || p1.is_a?(Array)
1343
- valid2 = p2.is_a?(cl1) || p2.is_a?(Array)
2716
+ pln = OpenStudio::Plane.new(p3)
1344
2717
 
1345
- return mismatch(i1, p1, cl1, mth, DBG, a) unless valid1
1346
- return mismatch(i2, p2, cl1, mth, DBG, a) unless valid2
1347
- return empty(i1, mth, ERR, a) if p1.empty?
1348
- return empty(i2, mth, ERR, a) if p2.empty?
2718
+ pts.each do |pt|
2719
+ return empty("plane", mth, ERR, v) unless pln.pointOnPlane(pt)
2720
+ end
1349
2721
 
1350
- p1.each { |v| return mismatch(i1, v, cl2, mth, ERR, a) unless v.is_a?(cl2) }
1351
- p2.each { |v| return mismatch(i2, v, cl2, mth, ERR, a) unless v.is_a?(cl2) }
2722
+ t = tt
2723
+ t = OpenStudio::Transformation.alignFace(pts) unless tt.is_a?(cl)
2724
+ a = (t.inverse * pts).reverse
1352
2725
 
1353
- # XY-plane transformation matrix ... needs to be clockwise for boost.
1354
- ft = OpenStudio::Transformation.alignFace(p1)
1355
- ft_p1 = flatZ( (ft.inverse * p1) )
1356
- return false if ft_p1.empty?
2726
+ if tt.is_a?(cl)
2727
+ # Using a transformation that is most likely not specific to pts. The
2728
+ # most probable reason to retain this option is when testing for polygon
2729
+ # intersections, unions, etc., operations that typically require that
2730
+ # points remain nonetheless 'aligned'. If re-activated, this logs a
2731
+ # warning if aligned points aren't @Z =0, before 'flattening'.
2732
+ #
2733
+ # invalid("points (non-aligned)", mth, 1, WRN) unless xyz?(a, :z, 0)
2734
+ a = flatten(a).to_a unless xyz?(a, :z, 0)
2735
+ end
1357
2736
 
1358
- cw = OpenStudio.pointInPolygon(ft_p1.first, ft_p1, TOL)
1359
- ft_p1 = flatZ( (ft.inverse * p1).reverse ) unless cw
1360
- ft_p2 = flatZ( (ft.inverse * p2).reverse ) unless cw
1361
- ft_p2 = flatZ( (ft.inverse * p2) ) if cw
1362
- return false if ft_p2.empty?
2737
+ # The following 2x lines are commented out. This is a very commnon and very
2738
+ # useful test, yet tested cases are first caught by the 'pointOnPlane'
2739
+ # test above. Keeping it for possible further testing.
2740
+ # bad = OpenStudio.selfIntersects(a, TOL)
2741
+ # return invalid("points (intersecting)", mth, 1, ERR, v) if bad
1363
2742
 
1364
- area1 = OpenStudio.getArea(ft_p1)
1365
- area2 = OpenStudio.getArea(ft_p2)
1366
- return empty("#{i1} area", mth, ERR, a) if area1.empty?
1367
- return empty("#{i2} area", mth, ERR, a) if area2.empty?
2743
+ # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- #
2744
+ # Ensure uniqueness and/or non-collinearity. Preserve original sequence.
2745
+ p0 = a.first
2746
+ a = OpenStudio.simplify(a, false, TOL) if uq
2747
+ a = OpenStudio.simplify(a, true, TOL) unless co
2748
+ i0 = a.index { |pt| same?(pt, p0) }
2749
+ a = a.rotate(i0) unless i0.nil?
1368
2750
 
1369
- area1 = area1.get
1370
- area2 = area2.get
1371
- union = OpenStudio.join(ft_p1, ft_p2, TOL2)
1372
- return false if union.empty?
2751
+ # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- #
2752
+ # Check for convexity (optional).
2753
+ if vx
2754
+ a1 = OpenStudio.simplify(a, true, TOL).reverse
2755
+ dX = a1.max_by(&:x).x.abs
2756
+ dY = a1.max_by(&:y).y.abs
2757
+ d = [dX, dY].max
2758
+ return false if d < TOL
2759
+
2760
+ u = OpenStudio::Vector3d.new(0, 0, d)
2761
+
2762
+ a1.each_with_index do |p1, i1|
2763
+ i2 = i1 + 1
2764
+ i2 = 0 if i2 == a1.size
2765
+ p2 = a1[i2]
2766
+ pi = p1 + u
2767
+ vi = OpenStudio::Point3dVector.new
2768
+ vi << pi
2769
+ vi << p1
2770
+ vi << p2
2771
+ plane = OpenStudio::Plane.new(vi)
2772
+ normal = plane.outwardNormal
2773
+
2774
+ a1.each do |p3|
2775
+ next if same?(p1, p3)
2776
+ next if same?(p2, p3)
2777
+ next if plane.pointOnPlane(p3)
2778
+ next if normal.dot(p3 - p1) < 0
2779
+
2780
+ return invalid("points (non-convex)", mth, 1, ERR, v)
2781
+ end
2782
+ end
2783
+ end
1373
2784
 
1374
- union = union.get
1375
- area = OpenStudio.getArea(union)
1376
- return empty("#{i1}:#{i2} union area", mth, ERR, a) if area.empty?
2785
+ # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- #
2786
+ # Alter sequence (optional).
2787
+ unless tt
2788
+ case sq
2789
+ when :ulc
2790
+ a = to_p3Dv(t * ulc(a.reverse))
2791
+ when :cw
2792
+ a = to_p3Dv(t * a)
2793
+ a = OpenStudio.reverse(a) unless clockwise?(a)
2794
+ else
2795
+ a = to_p3Dv(t * a.reverse)
2796
+ end
2797
+ else
2798
+ case sq
2799
+ when :ulc
2800
+ a = ulc(a.reverse)
2801
+ when :cw
2802
+ a = to_p3Dv(a)
2803
+ a = OpenStudio.reverse(a) unless clockwise?(a)
2804
+ else
2805
+ a = to_p3Dv(a.reverse)
2806
+ end
2807
+ end
1377
2808
 
1378
- area = area.get
2809
+ a
2810
+ end
1379
2811
 
1380
- return false if area < TOL
1381
- return true if (area - area2).abs < TOL
1382
- return false if (area - area2).abs > TOL
2812
+ ##
2813
+ # Returns 'width' of a set of OpenStudio 3D points (perpendicular view).
2814
+ #
2815
+ # @param pts [Set<OpenStudio::Point3d>] 3D points
2816
+ #
2817
+ # @return [Float] left-to-right width
2818
+ # @return [0.0] if invalid inputs (see logs)
2819
+ def width(pts = nil)
2820
+ mth = "OSut::#{__callee__}"
1383
2821
 
1384
- true
2822
+ poly(pts, false, true, false, true).max_by(&:x).x
1385
2823
  end
1386
2824
 
1387
2825
  ##
1388
- # Validate whether an OpenStudio polygon overlaps another.
2826
+ # Returns 'height' of a set of OpenStudio 3D points (perpendicular view).
1389
2827
  #
1390
- # @param p1 [OpenStudio::Point3dVector] or Point3D array of polygon #1
1391
- # @param p2 [OpenStudio::Point3dVector] or Point3D array of polygon #2
1392
- # @param id1 [String] polygon #1 identifier (optional)
1393
- # @param id2 [String] polygon #2 identifier (optional)
2828
+ # @param pts [Set<OpenStudio::Point3d>] 3D points
1394
2829
  #
1395
- # @return Returns true if polygons overlaps (or either fits into the other)
1396
- # @return [Bool] false if invalid input
1397
- def overlaps?(p1 = nil, p2 = nil, id1 = "", id2 = "")
2830
+ # @return [Float] top-to-bottom height
2831
+ # @return [0.0] if invalid inputs (see logs)
2832
+ def height(pts = nil)
1398
2833
  mth = "OSut::#{__callee__}"
1399
- cl1 = OpenStudio::Point3dVector
1400
- cl2 = OpenStudio::Point3d
1401
- a = false
1402
-
1403
- return invalid("id1", mth, 3, DBG, a) unless id1.respond_to?(:to_s)
1404
- return invalid("id2", mth, 4, DBG, a) unless id2.respond_to?(:to_s)
1405
-
1406
- i1 = id1.to_s
1407
- i2 = id2.to_s
1408
- i1 = "poly1" if i1.empty?
1409
- i2 = "poly2" if i2.empty?
1410
-
1411
- valid1 = p1.is_a?(cl1) || p1.is_a?(Array)
1412
- valid2 = p2.is_a?(cl1) || p2.is_a?(Array)
1413
-
1414
- return mismatch(i1, p1, cl1, mth, DBG, a) unless valid1
1415
- return mismatch(i2, p2, cl1, mth, DBG, a) unless valid2
1416
- return empty(i1, mth, ERR, a) if p1.empty?
1417
- return empty(i2, mth, ERR, a) if p2.empty?
1418
-
1419
- p1.each { |v| return mismatch(i1, v, cl2, mth, ERR, a) unless v.is_a?(cl2) }
1420
- p2.each { |v| return mismatch(i2, v, cl2, mth, ERR, a) unless v.is_a?(cl2) }
1421
-
1422
- # XY-plane transformation matrix ... needs to be clockwise for boost.
1423
- ft = OpenStudio::Transformation.alignFace(p1)
1424
- ft_p1 = flatZ( (ft.inverse * p1) )
1425
- ft_p2 = flatZ( (ft.inverse * p2) )
1426
- return false if ft_p1.empty?
1427
- return false if ft_p2.empty?
1428
-
1429
- cw = OpenStudio.pointInPolygon(ft_p1.first, ft_p1, TOL)
1430
- ft_p1 = flatZ( (ft.inverse * p1).reverse ) unless cw
1431
- ft_p2 = flatZ( (ft.inverse * p2).reverse ) unless cw
1432
- return false if ft_p1.empty?
1433
- return false if ft_p2.empty?
1434
-
1435
- area1 = OpenStudio.getArea(ft_p1)
1436
- area2 = OpenStudio.getArea(ft_p2)
1437
- return empty("#{i1} area", mth, ERR, a) if area1.empty?
1438
- return empty("#{i2} area", mth, ERR, a) if area2.empty?
2834
+
2835
+ poly(pts, false, true, false, true).max_by(&:y).y
2836
+ end
2837
+
2838
+ ##
2839
+ # Determines whether a 1st OpenStudio polygon fits in a 2nd polygon.
2840
+ #
2841
+ # @param p1 [Set<OpenStudio::Point3d>] 1st set of 3D points
2842
+ # @param p2 [Set<OpenStudio::Point3d>] 2nd set of 3D points
2843
+ # @param flat [Bool] whether points are to be pre-flattened (Z=0)
2844
+ #
2845
+ # @return [Bool] whether 1st polygon fits within the 2nd polygon
2846
+ # @return [false] if invalid input (see logs)
2847
+ def fits?(p1 = nil, p2 = nil, flat = true)
2848
+ mth = "OSut::#{__callee__}"
2849
+ p1 = poly(p1, false, true, false)
2850
+ p2 = poly(p2, false, true, false)
2851
+ flat = true unless [true, false].include?(flat)
2852
+ return false if p1.empty?
2853
+ return false if p2.empty?
2854
+
2855
+ # Aligned, clockwise points using transformation from 2nd polygon.
2856
+ t = OpenStudio::Transformation.alignFace(p2)
2857
+ p1 = poly(p1, false, false, true, t, :cw)
2858
+ p2 = poly(p2, false, false, true, t, :cw)
2859
+ p1 = flatten(p1) if flat
2860
+ p2 = flatten(p2) if flat
2861
+ return false if p1.empty?
2862
+ return false if p2.empty?
2863
+
2864
+ area1 = OpenStudio.getArea(p1)
2865
+ area2 = OpenStudio.getArea(p2)
2866
+ return empty("points 1 area", mth, ERR, false) if area1.empty?
2867
+ return empty("points 2 area", mth, ERR, false) if area2.empty?
1439
2868
 
1440
2869
  area1 = area1.get
1441
2870
  area2 = area2.get
1442
- union = OpenStudio.join(ft_p1, ft_p2, TOL2)
1443
- return false if union.empty?
2871
+ union = OpenStudio.join(p1, p2, TOL2)
2872
+ return false if union.empty?
1444
2873
 
1445
2874
  union = union.get
1446
2875
  area = OpenStudio.getArea(union)
1447
- return empty("#{i1}:#{i2} union area", mth, ERR, a) if area.empty?
2876
+ return false if area.empty?
1448
2877
 
1449
2878
  area = area.get
1450
- return false if area < TOL
1451
2879
 
1452
- delta = (area - area1 - area2).abs
1453
- return false if delta < TOL
2880
+ if area > TOL
2881
+ return true if (area - area2).abs < TOL
2882
+ end
1454
2883
 
1455
- true
2884
+ false
1456
2885
  end
1457
2886
 
1458
2887
  ##
1459
- # Generate offset vertices (by width) for a 3- or 4-sided, convex polygon.
2888
+ # Determines whether OpenStudio polygons overlap.
1460
2889
  #
1461
- # @param p1 [OpenStudio::Point3dVector] OpenStudio Point3D vector/array
1462
- # @param w [Float] offset width (min: 0.0254m)
1463
- # @param v [Integer] OpenStudio SDK version, eg '321' for 'v3.2.1' (optional)
2890
+ # @param p1 [Set<OpenStudio::Point3d>] 1st set of 3D points
2891
+ # @param p2 [Set<OpenStudio::Point3d>] 2nd set of 3D points
2892
+ # @param flat [Bool] whether points are to be pre-flattened (Z=0)
1464
2893
  #
1465
- # @return [OpenStudio::Point3dVector] offset points if successful
1466
- # @return [OpenStudio::Point3dVector] original points if invalid input
1467
- def offset(p1 = [], w = 0, v = 0)
1468
- mth = "OSut::#{__callee__}"
1469
- cl = OpenStudio::Point3d
1470
- vrsn = OpenStudio.openStudioVersion.split(".").map(&:to_i).join.to_i
2894
+ # @return [Bool] whether polygons overlap (or fit)
2895
+ # @return [false] if invalid input (see logs)
2896
+ def overlaps?(p1 = nil, p2 = nil, flat = true)
2897
+ mth = "OSut::#{__callee__}"
2898
+ p1 = poly(p1, false, true, false)
2899
+ p2 = poly(p2, false, true, false)
2900
+ flat = true unless [true, false].include?(flat)
2901
+ return false if p1.empty?
2902
+ return false if p2.empty?
2903
+
2904
+ # Aligned, clockwise & convex points using transformation from 1st polygon.
2905
+ t = OpenStudio::Transformation.alignFace(p1)
2906
+ p1 = poly(p1, false, false, true, t, :cw)
2907
+ p2 = poly(p2, false, false, true, t, :cw)
2908
+ p1 = flatten(p1) if flat
2909
+ p2 = flatten(p2) if flat
2910
+ return false if p1.empty?
2911
+ return false if p2.empty?
2912
+
2913
+ return true if fits?(p1, p2)
2914
+ return true if fits?(p2, p1)
2915
+
2916
+ area1 = OpenStudio.getArea(p1)
2917
+ area2 = OpenStudio.getArea(p2)
2918
+ return empty("points 1 area", mth, ERR, false) if area1.empty?
2919
+ return empty("points 2 area", mth, ERR, false) if area2.empty?
1471
2920
 
1472
- valid = p1.is_a?(OpenStudio::Point3dVector) || p1.is_a?(Array)
1473
- return mismatch("pts", p1, cl1, mth, DBG, p1) unless valid
1474
- return empty("pts", mth, ERR, p1) if p1.empty?
2921
+ area1 = area1.get
2922
+ area2 = area2.get
2923
+ union = OpenStudio.join(p1, p2, TOL2)
2924
+ return false if union.empty?
1475
2925
 
1476
- valid = p1.size == 3 || p1.size == 4
1477
- iv = true if p1.size == 4
1478
- return invalid("pts", mth, 1, DBG, p1) unless valid
1479
- return invalid("width", mth, 2, DBG, p1) unless w.respond_to?(:to_f)
2926
+ union = union.get
2927
+ area = OpenStudio.getArea(union)
2928
+ return false if area.empty?
1480
2929
 
1481
- w = w.to_f
1482
- return p1 if w < 0.0254
2930
+ area = area.get
2931
+ delta = area1 + area2 - area
1483
2932
 
1484
- v = v.to_i if v.respond_to?(:to_i)
1485
- v = 0 unless v.respond_to?(:to_i)
1486
- v = vrsn if v.zero?
2933
+ if area > TOL
2934
+ return false if (area - area1).abs < TOL
2935
+ return false if (area - area2).abs < TOL
2936
+ return false if delta.abs < TOL
2937
+ return true if delta > TOL
2938
+ end
1487
2939
 
1488
- p1.each { |x| return mismatch("p", x, cl, mth, ERR, p1) unless x.is_a?(cl) }
2940
+ false
2941
+ end
1489
2942
 
1490
- unless v < 340
1491
- # XY-plane transformation matrix ... needs to be clockwise for boost.
1492
- ft = OpenStudio::Transformation::alignFace(p1)
1493
- ft_pts = flatZ( (ft.inverse * p1) )
1494
- return p1 if ft_pts.empty?
2943
+ ##
2944
+ # Generates offset vertices (by width) for a 3- or 4-sided, convex polygon.
2945
+ #
2946
+ # @param p1 [Set<OpenStudio::Point3d>] OpenStudio 3D points
2947
+ # @param w [#to_f] offset width (min: 0.0254m)
2948
+ # @param v [#to_i] OpenStudio SDK version, eg '321' for "v3.2.1" (optional)
2949
+ #
2950
+ # @return [OpenStudio::Point3dVector] offset points (see logs if unaltered)
2951
+ def offset(p1 = nil, w = 0, v = 0)
2952
+ mth = "OSut::#{__callee__}"
2953
+ pts = poly(p1, true, true, false, true, :cw)
2954
+ return invalid("points", mth, 1, DBG, p1) unless [3, 4].include?(pts.size)
1495
2955
 
1496
- cw = OpenStudio::pointInPolygon(ft_pts.first, ft_pts, TOL)
1497
- ft_pts = flatZ( (ft.inverse * p1).reverse ) unless cw
1498
- offset = OpenStudio.buffer(ft_pts, w, TOL)
1499
- return p1 if offset.empty?
2956
+ mismatch("width", w, Numeric, mth) unless w.respond_to?(:to_f)
2957
+ mismatch("version", v, Integer, mth) unless v.respond_to?(:to_i)
1500
2958
 
1501
- offset = offset.get
1502
- offset = ft * offset if cw
1503
- offset = (ft * offset).reverse unless cw
2959
+ vs = OpenStudio.openStudioVersion.split(".").join.to_i
2960
+ iv = true if pts.size == 4
2961
+ v = v.to_i if v.respond_to?(:to_i)
2962
+ v = -1 unless v.respond_to?(:to_i)
2963
+ v = vs if v < 0
2964
+ w = w.to_f if w.respond_to?(:to_f)
2965
+ w = 0 unless w.respond_to?(:to_f)
2966
+ w = 0 if w < 0.0254
1504
2967
 
1505
- pz = OpenStudio::Point3dVector.new
1506
- offset.each { |o| pz << OpenStudio::Point3d.new(o.x, o.y, o.z ) }
2968
+ unless v < 340
2969
+ t = OpenStudio::Transformation.alignFace(p1)
2970
+ offset = OpenStudio.buffer(pts, w, TOL)
2971
+ return p1 if offset.empty?
1507
2972
 
1508
- return pz
1509
- else # brute force approach
2973
+ return to_p3Dv(t * offset.get.reverse)
2974
+ else # brute force approach
1510
2975
  pz = {}
1511
2976
  pz[:A] = {}
1512
2977
  pz[:B] = {}
@@ -1690,81 +3155,347 @@ module OSut
1690
3155
  end
1691
3156
 
1692
3157
  ##
1693
- # Validate whether an OpenStudio planar surface is safe to process.
3158
+ # Generates a ULC OpenStudio 3D point vector (a bounding box) that surrounds
3159
+ # multiple (smaller) OpenStudio 3D point vectors. The generated, 4-point
3160
+ # outline is optionally buffered (or offset). Frame and Divider frame widths
3161
+ # are taken into account.
3162
+ #
3163
+ # @param a [Array] sets of OpenStudio 3D points
3164
+ # @param bfr [Numeric] an optional buffer size (min: 0.0254m)
3165
+ # @param flat [Bool] if points are to be pre-flattened (Z=0)
3166
+ #
3167
+ # @return [OpenStudio::Point3dVector] ULC outline (see logs if empty)
3168
+ def outline(a = [], bfr = 0, flat = true)
3169
+ mth = "OSut::#{__callee__}"
3170
+ flat = true unless [true, false].include?(flat)
3171
+ xMIN = nil
3172
+ xMAX = nil
3173
+ yMIN = nil
3174
+ yMAX = nil
3175
+ a2 = []
3176
+ out = OpenStudio::Point3dVector.new
3177
+ cl = Array
3178
+ return mismatch("array", a, cl, mth, DBG, out) unless a.is_a?(cl)
3179
+ return empty("array", mth, DBG, out) if a.empty?
3180
+
3181
+ mismatch("buffer", bfr, Numeric, mth) unless bfr.respond_to?(:to_f)
3182
+
3183
+ bfr = bfr.to_f if bfr.respond_to?(:to_f)
3184
+ bfr = 0 unless bfr.respond_to?(:to_f)
3185
+ bfr = 0 if bfr < 0.0254
3186
+ vtx = poly(a.first)
3187
+ t = OpenStudio::Transformation.alignFace(vtx) unless vtx.empty?
3188
+ return out if vtx.empty?
3189
+
3190
+ a.each do |pts|
3191
+ points = poly(pts, false, true, false, t)
3192
+ points = flatten(points) if flat
3193
+ next if points.empty?
3194
+
3195
+ a2 << points
3196
+ end
3197
+
3198
+ a2.each do |pts|
3199
+ minX = pts.min_by(&:x).x
3200
+ maxX = pts.max_by(&:x).x
3201
+ minY = pts.min_by(&:y).y
3202
+ maxY = pts.max_by(&:y).y
3203
+
3204
+ # Consider frame width, if frame-and-divider-enabled sub surface.
3205
+ if pts.respond_to?(:allowWindowPropertyFrameAndDivider)
3206
+ fd = pts.windowPropertyFrameAndDivider
3207
+ w = 0
3208
+ w = fd.get.frameWidth unless fd.empty?
3209
+
3210
+ if w > TOL
3211
+ minX -= w
3212
+ maxX += w
3213
+ minY -= w
3214
+ maxY += w
3215
+ end
3216
+ end
3217
+
3218
+ xMIN = minX if xMIN.nil?
3219
+ xMAX = maxX if xMAX.nil?
3220
+ yMIN = minY if yMIN.nil?
3221
+ yMAX = maxY if yMAX.nil?
3222
+
3223
+ xMIN = [xMIN, minX].min
3224
+ xMAX = [xMAX, maxX].max
3225
+ yMIN = [yMIN, minY].min
3226
+ yMAX = [yMAX, maxY].max
3227
+ end
3228
+
3229
+ return negative("outline width", mth, DBG, out) if xMAX < xMIN
3230
+ return negative("outline height", mth, DBG, out) if yMAX < yMIN
3231
+ return zero("outline width", mth, DBG, out) if (xMIN - xMAX).abs < TOL
3232
+ return zero("outline height", mth, DBG, out) if (yMIN - yMAX).abs < TOL
3233
+
3234
+ # Generate ULC point 3D vector.
3235
+ out << OpenStudio::Point3d.new(xMIN, yMAX, 0)
3236
+ out << OpenStudio::Point3d.new(xMIN, yMIN, 0)
3237
+ out << OpenStudio::Point3d.new(xMAX, yMIN, 0)
3238
+ out << OpenStudio::Point3d.new(xMAX, yMAX, 0)
3239
+
3240
+ # Apply buffer, apply ULC (options).
3241
+ out = offset(out, bfr, 300) if bfr > 0.0254
3242
+
3243
+ to_p3Dv(t * out)
3244
+ end
3245
+
3246
+ ##
3247
+ # Returns an array of OpenStudio space-specific surfaces that match criteria,
3248
+ # e.g. exterior, north-east facing walls in hotel "lobby". Note 'sides' rely
3249
+ # on space coordinates (not absolute model coordinates). And 'sides' are
3250
+ # exclusive, not inclusive (e.g. walls strictly north-facing or strictly
3251
+ # east-facing would not be returned if 'sides' holds [:north, :east]).
3252
+ #
3253
+ # @param spaces [Array<OpenStudio::Model::Space>] target spaces
3254
+ # @param boundary [#to_s] OpenStudio outside boundary condition
3255
+ # @param type [#to_s] OpenStudio surface type
3256
+ # @param sides [Array<Symbols>] direction keys, e.g. :north (see OSut::SIDZ)
3257
+ #
3258
+ # @return [Array<OpenStudio::Model::Surface>] surfaces (may be empty)
3259
+ def facets(spaces = [], boundary = "Outdoors", type = "Wall", sides = [])
3260
+ return [] unless spaces.respond_to?(:&)
3261
+ return [] unless sides.respond_to?(:&)
3262
+ return [] if sides.empty?
3263
+
3264
+ faces = []
3265
+ boundary = trim(boundary).downcase
3266
+ type = trim(type).downcase
3267
+ return [] if boundary.empty?
3268
+ return [] if type.empty?
3269
+
3270
+ # Keep valid sides.
3271
+ sides = sides.select { |side| SIDZ.include?(side) }
3272
+ return [] if sides.empty?
3273
+
3274
+ spaces.each do |space|
3275
+ return [] unless space.respond_to?(:setSpaceType)
3276
+
3277
+ space.surfaces.each do |s|
3278
+ next unless s.outsideBoundaryCondition.downcase == boundary
3279
+ next unless s.surfaceType.downcase == type
3280
+
3281
+ orientations = []
3282
+ orientations << :top if s.outwardNormal.z > TOL
3283
+ orientations << :bottom if s.outwardNormal.z < -TOL
3284
+ orientations << :north if s.outwardNormal.y > TOL
3285
+ orientations << :east if s.outwardNormal.x > TOL
3286
+ orientations << :south if s.outwardNormal.y < -TOL
3287
+ orientations << :west if s.outwardNormal.x < -TOL
3288
+
3289
+ faces << s if sides.all? { |o| orientations.include?(o) }
3290
+ end
3291
+ end
3292
+
3293
+ faces
3294
+ end
3295
+
3296
+ ##
3297
+ # Generates an OpenStudio 3D point vector of a composite floor "slab", a
3298
+ # 'union' of multiple rectangular, horizontal floor "plates". Each plate
3299
+ # must either share an edge with (or encompass or overlap) any of the
3300
+ # preceding plates in the array. The generated slab may not be convex.
3301
+ #
3302
+ # @param [Array<Hash>] pltz individual floor plates, each holding:
3303
+ # @option pltz [Numeric] :x left corner of plate origin (bird's eye view)
3304
+ # @option pltz [Numeric] :y bottom corner of plate origin (bird's eye view)
3305
+ # @option pltz [Numeric] :dx plate width (bird's eye view)
3306
+ # @option pltz [Numeric] :dy plate depth (bird's eye view)
3307
+ # @param z [Numeric] Z-axis coordinate
3308
+ #
3309
+ # @return [OpenStudio::Point3dVector] slab vertices (see logs if empty)
3310
+ def genSlab(pltz = [], z = 0)
3311
+ mth = "OSut::#{__callee__}"
3312
+ slb = OpenStudio::Point3dVector.new
3313
+ bkp = OpenStudio::Point3dVector.new
3314
+ cl1 = Array
3315
+ cl2 = Hash
3316
+ cl3 = Numeric
3317
+
3318
+ # Input validation.
3319
+ return mismatch("plates", pltz, cl1, mth, DBG, slb) unless pltz.is_a?(cl1)
3320
+ return mismatch( "Z", z, cl3, mth, DBG, slb) unless z.is_a?(cl3)
3321
+
3322
+ pltz.each_with_index do |plt, i|
3323
+ id = "plate # #{i+1} (index #{i})"
3324
+
3325
+ return mismatch(id, plt, cl1, mth, DBG, slb) unless plt.is_a?(cl2)
3326
+ return hashkey( id, plt, :x, mth, DBG, slb) unless plt.key?(:x )
3327
+ return hashkey( id, plt, :y, mth, DBG, slb) unless plt.key?(:y )
3328
+ return hashkey( id, plt, :dx, mth, DBG, slb) unless plt.key?(:dx)
3329
+ return hashkey( id, plt, :dy, mth, DBG, slb) unless plt.key?(:dy)
3330
+
3331
+ x = plt[:x ]
3332
+ y = plt[:y ]
3333
+ dx = plt[:dx]
3334
+ dy = plt[:dy]
3335
+
3336
+ return mismatch("#{id} X", x, cl3, mth, DBG, slb) unless x.is_a?(cl3)
3337
+ return mismatch("#{id} Y", y, cl3, mth, DBG, slb) unless y.is_a?(cl3)
3338
+ return mismatch("#{id} dX", dx, cl3, mth, DBG, slb) unless dx.is_a?(cl3)
3339
+ return mismatch("#{id} dY", dy, cl3, mth, DBG, slb) unless dy.is_a?(cl3)
3340
+ return zero( "#{id} dX", mth, ERR, slb) if dx.abs < TOL
3341
+ return zero( "#{id} dY", mth, ERR, slb) if dy.abs < TOL
3342
+ end
3343
+
3344
+ # Join plates.
3345
+ pltz.each_with_index do |plt, i|
3346
+ id = "plate # #{i+1} (index #{i})"
3347
+ x = plt[:x ]
3348
+ y = plt[:y ]
3349
+ dx = plt[:dx]
3350
+ dy = plt[:dy]
3351
+
3352
+ # Adjust X if dX < 0.
3353
+ x -= -dx if dx < 0
3354
+ dx = -dx if dx < 0
3355
+
3356
+ # Adjust Y if dY < 0.
3357
+ y -= -dy if dy < 0
3358
+ dy = -dy if dy < 0
3359
+
3360
+ vtx = []
3361
+ vtx << OpenStudio::Point3d.new(x + dx, y + dy, 0)
3362
+ vtx << OpenStudio::Point3d.new(x + dx, y, 0)
3363
+ vtx << OpenStudio::Point3d.new(x, y, 0)
3364
+ vtx << OpenStudio::Point3d.new(x, y + dy, 0)
3365
+
3366
+ if slb.empty?
3367
+ slb = vtx
3368
+ else
3369
+ slab = OpenStudio.join(slb, vtx, TOL2)
3370
+ slb = slab.get unless slab.empty?
3371
+ return invalid(id, mth, 0, ERR, bkp) if slab.empty?
3372
+ end
3373
+ end
3374
+
3375
+ # Once joined, re-adjust Z-axis coordinates.
3376
+ unless z.zero?
3377
+ vtx = OpenStudio::Point3dVector.new
3378
+ slb.each { |pt| vtx << OpenStudio::Point3d.new(pt.x, pt.y, z) }
3379
+ slb = vtx
3380
+ end
3381
+
3382
+ slb
3383
+ end
3384
+
3385
+ ##
3386
+ # Returns outdoor-facing, space-(related) roof/ceiling surfaces. These
3387
+ # include outdoor-facing roof/ceilings of the space per se, as well as
3388
+ # any outside-facing roof/ceiling surface of an unoccupied space
3389
+ # immediately above (e.g. a plenum) overlapping any of the roof/ceilings
3390
+ # of the space itself.
1694
3391
  #
1695
- # @param s [OpenStudio::Model::PlanarSurface] a surface
3392
+ # @param space [OpenStudio::Model::Space] a space
1696
3393
  #
1697
- # @return [Bool] true if valid surface
1698
- def surface_valid?(s = nil)
3394
+ # @return [Array<OpenStudio::Model::Surface>] surfaces (see logs if empty)
3395
+ def getRoofs(space = nil)
1699
3396
  mth = "OSut::#{__callee__}"
1700
- cl = OpenStudio::Model::PlanarSurface
3397
+ cl = OpenStudio::Model::Space
3398
+ return mismatch("space", space, cl, mth, DBG, []) unless space.is_a?(cl)
3399
+
3400
+ roofs = space.surfaces # outdoor-facing roofs of the space
3401
+ clngs = space.surfaces # surface-facing ceilings of the space
3402
+
3403
+ roofs = roofs.select {|s| s.surfaceType.downcase == "roofceiling"}
3404
+ roofs = roofs.select {|s| s.outsideBoundaryCondition.downcase == "outdoors"}
1701
3405
 
1702
- return mismatch("surface", s, cl, mth, DBG, false) unless s.is_a?(cl)
3406
+ clngs = clngs.select {|s| s.surfaceType.downcase == "roofceiling"}
3407
+ clngs = clngs.select {|s| s.outsideBoundaryCondition.downcase == "surface"}
1703
3408
 
1704
- id = s.nameString
1705
- size = s.vertices.size
1706
- last = size - 1
3409
+ clngs.each do |ceiling|
3410
+ floor = ceiling.adjacentSurface
3411
+ next if floor.empty?
1707
3412
 
1708
- log(ERR, "#{id} #{size} vertices? need +3 (#{mth})") unless size > 2
1709
- return false unless size > 2
3413
+ other = floor.get.space
3414
+ next if other.empty?
1710
3415
 
1711
- [0, last].each do |i|
1712
- v1 = s.vertices[i]
1713
- v2 = s.vertices[i + 1] unless i == last
1714
- v2 = s.vertices.first if i == last
1715
- vec = v2 - v1
1716
- bad = vec.length < TOL
3416
+ rufs = other.get.surfaces
1717
3417
 
1718
- # As is, this comparison also catches collinear vertices (< 10mm apart)
1719
- # along an edge. Should avoid red-flagging such cases. TO DO.
1720
- log(ERR, "#{id}: < #{TOL}m (#{mth})") if bad
1721
- return false if bad
3418
+ rufs = rufs.select {|s| s.surfaceType.downcase == "roofceiling"}
3419
+ rufs = rufs.select {|s| s.outsideBoundaryCondition.downcase == "outdoors"}
3420
+ next if rufs.empty?
3421
+
3422
+ # Only keep track of "other" roof(s) that "overlap" ceiling below.
3423
+ rufs.each do |ruf|
3424
+ next unless overlaps?(ceiling, ruf)
3425
+
3426
+ roofs << ruf unless roofs.include?(ruf)
3427
+ end
1722
3428
  end
1723
3429
 
1724
- # Add as many extra tests as needed ...
1725
- true
3430
+ roofs
1726
3431
  end
1727
3432
 
1728
3433
  ##
1729
- # Add sub surfaces (e.g. windows, doors, skylights) to surface.
3434
+ # Adds sub surfaces (e.g. windows, doors, skylights) to surface.
1730
3435
  #
1731
- # @param model [OpenStudio::Model::Model] a model
1732
3436
  # @param s [OpenStudio::Model::Surface] a model surface
1733
- # @param subs [Array] requested sub surface attributes
1734
- # @param clear [Bool] remove current sub surfaces if true
1735
- # @param bfr [Double] safety buffer (m), when ~aligned along other edges
1736
- #
1737
- # @return [Bool] true if successful (check for logged messages if failures)
1738
- def addSubs(model = nil, s = nil, subs = [], clear = false, bfr = 0.005)
3437
+ # @param [Array<Hash>] subs requested attributes
3438
+ # @option subs [#to_s] :id identifier e.g. "Window 007"
3439
+ # @option subs [#to_s] :type ("FixedWindow") OpenStudio subsurface type
3440
+ # @option subs [#to_i] :count (1) number of individual subs per array
3441
+ # @option subs [#to_i] :multiplier (1) OpenStudio subsurface multiplier
3442
+ # @option subs [#frameWidth] :frame (nil) OpenStudio frame & divider object
3443
+ # @option subs [#isFenestration] :assembly (nil) OpenStudio construction
3444
+ # @option subs [#to_f] :ratio e.g. %FWR [0.0, 1.0]
3445
+ # @option subs [#to_f] :head (OSut::HEAD) e.g. door height (incl frame)
3446
+ # @option subs [#to_f] :sill (OSut::SILL) e.g. window sill (incl frame)
3447
+ # @option subs [#to_f] :height sill-to-head height
3448
+ # @option subs [#to_f] :width e.g. door width
3449
+ # @option subs [#to_f] :offset left-right centreline dX e.g. between doors
3450
+ # @option subs [#to_f] :centreline left-right dX (sub/array vs base)
3451
+ # @option subs [#to_f] :r_buffer gap between sub/array and right corner
3452
+ # @option subs [#to_f] :l_buffer gap between sub/array and left corner
3453
+ # @param clear [Bool] whether to remove current sub surfaces
3454
+ # @param bfr [#to_f] safety buffer, to maintain near other edges
3455
+ #
3456
+ # @return [Bool] whether addition is successful
3457
+ # @return [false] if invalid input (see logs)
3458
+ def addSubs(s = nil, subs = [], clear = false, bfr = 0.005)
1739
3459
  mth = "OSut::#{__callee__}"
1740
3460
  v = OpenStudio.openStudioVersion.split(".").join.to_i
1741
- cl1 = OpenStudio::Model::Model
1742
- cl2 = OpenStudio::Model::Surface
1743
- cl3 = Array
1744
- cl4 = Hash
1745
- cl5 = Numeric
3461
+ cl1 = OpenStudio::Model::Surface
3462
+ cl2 = Array
3463
+ cl3 = Hash
1746
3464
  min = 0.050 # minimum ratio value ( 5%)
1747
3465
  max = 0.950 # maximum ratio value (95%)
1748
3466
  no = false
1749
3467
 
1750
3468
  # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- #
1751
3469
  # Exit if mismatched or invalid argument classes.
1752
- return mismatch("model", model, cl1, mth, DBG, no) unless model.is_a?(cl1)
1753
- return mismatch("surface", s, cl2, mth, DBG, no) unless s.is_a?(cl2)
1754
- return mismatch("subs", subs, cl3, mth, DBG, no) unless subs.is_a?(cl3)
1755
- return no unless surface_valid?(s)
3470
+ return mismatch("surface", s, cl2, mth, DBG, no) unless s.is_a?(cl1)
3471
+ return mismatch("subs", subs, cl3, mth, DBG, no) unless subs.is_a?(cl2)
3472
+ return empty("surface points", mth, DBG, no) if poly(s).empty?
1756
3473
 
1757
3474
  # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- #
1758
3475
  # Clear existing sub surfaces if requested.
1759
3476
  nom = s.nameString
3477
+ mdl = s.model
1760
3478
 
1761
- unless clear == true || clear == false
3479
+ unless [true, false].include?(clear)
1762
3480
  log(WRN, "#{nom}: Keeping existing sub surfaces (#{mth})")
1763
3481
  clear = false
1764
3482
  end
1765
3483
 
1766
3484
  s.subSurfaces.map(&:remove) if clear
1767
3485
 
3486
+ # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- #
3487
+ # Ensure minimum safety buffer.
3488
+ if bfr.respond_to?(:to_f)
3489
+ bfr = bfr.to_f
3490
+ return negative("safety buffer", mth, ERR, no) if bfr < 0
3491
+
3492
+ msg = "Safety buffer < 5mm may generate invalid geometry (#{mth})"
3493
+ log(WRN, msg) if bfr < 0.005
3494
+ else
3495
+ log(ERR, "Setting safety buffer to 5mm (#{mth})")
3496
+ bfr = 0.005
3497
+ end
3498
+
1768
3499
  # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- #
1769
3500
  # Allowable sub surface types ... & Frame&Divider enabled
1770
3501
  # - "FixedWindow" | true
@@ -1780,47 +3511,36 @@ module OSut
1780
3511
  stype = s.surfaceType # Wall, RoofCeiling or Floor
1781
3512
 
1782
3513
  # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- #
1783
- # Fetch transform, as if host surface vertices were to "align", i.e.:
1784
- # - rotated/tilted ... then flattened along XY plane
1785
- # - all Z-axis coordinates ~= 0
1786
- # - vertices with the lowest X-axis values are "aligned" along X-axis (0)
1787
- # - vertices with the lowest Z-axis values are "aligned" along Y-axis (0)
1788
- # - Z-axis values are represented as Y-axis values
1789
- tr = OpenStudio::Transformation.alignFace(s.vertices)
1790
-
1791
- # Aligned vertices of host surface, and fetch attributes.
1792
- aligned = tr.inverse * s.vertices
1793
- max_x = aligned.max_by(&:x).x
1794
- max_y = aligned.max_by(&:y).y
1795
- mid_x = max_x / 2
1796
- mid_y = max_y / 2
3514
+ t = OpenStudio::Transformation.alignFace(s.vertices)
3515
+ max_x = width(s)
3516
+ max_y = height(s)
3517
+ mid_x = max_x / 2
3518
+ mid_y = max_y / 2
1797
3519
 
1798
3520
  # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- #
1799
3521
  # Assign default values to certain sub keys (if missing), +more validation.
1800
3522
  subs.each_with_index do |sub, index|
1801
- return mismatch("sub", sub, cl4, mth, DBG, no) unless sub.is_a?(cl4)
3523
+ return mismatch("sub", sub, cl4, mth, DBG, no) unless sub.is_a?(cl3)
1802
3524
 
1803
3525
  # Required key:value pairs (either set by the user or defaulted).
1804
- sub[:id ] = "" unless sub.key?(:id ) # "Window 007"
1805
- sub[:type ] = type unless sub.key?(:type ) # "FixedWindow"
1806
- sub[:count ] = 1 unless sub.key?(:count ) # for an array
1807
- sub[:multiplier] = 1 unless sub.key?(:multiplier)
1808
- sub[:frame ] = nil unless sub.key?(:frame ) # frame/divider
1809
- sub[:assembly ] = nil unless sub.key?(:assembly ) # construction
1810
-
1811
- # Optional key:value pairs.
1812
- # sub[:ratio ] # e.g. %FWR
1813
- # sub[:head ] # e.g. std 80" door + frame/buffers (+ m)
1814
- # sub[:sill ] # e.g. std 30" sill + frame/buffers (+ m)
1815
- # sub[:height ] # any sub surface height, below "head" (+ m)
1816
- # sub[:width ] # e.g. 1.200 m
1817
- # sub[:offset ] # if array (+ m)
1818
- # sub[:centreline] # left or right of base surface centreline (+/- m)
1819
- # sub[:r_buffer ] # buffer between sub/array and right-side corner (+ m)
1820
- # sub[:l_buffer ] # buffer between sub/array and left-side corner (+ m)
1821
-
1822
- sub[:id] = "#{nom}|#{index}" if sub[:id].empty?
1823
- id = sub[:id]
3526
+ sub[:frame ] = nil unless sub.key?(:frame )
3527
+ sub[:assembly ] = nil unless sub.key?(:assembly )
3528
+ sub[:count ] = 1 unless sub.key?(:count )
3529
+ sub[:multiplier] = 1 unless sub.key?(:multiplier)
3530
+ sub[:id ] = "" unless sub.key?(:id )
3531
+ sub[:type ] = type unless sub.key?(:type )
3532
+ sub[:type ] = trim(sub[:type])
3533
+ sub[:id ] = trim(sub[:id])
3534
+ sub[:type ] = type if sub[:type].empty?
3535
+ sub[:id ] = "OSut|#{nom}|#{index}" if sub[:id ].empty?
3536
+ sub[:count ] = 1 unless sub[:count ].respond_to?(:to_i)
3537
+ sub[:multiplier] = 1 unless sub[:multiplier].respond_to?(:to_i)
3538
+ sub[:count ] = sub[:count ].to_i
3539
+ sub[:multiplier] = sub[:multiplier].to_i
3540
+ sub[:count ] = 1 if sub[:count ] < 1
3541
+ sub[:multiplier] = 1 if sub[:multiplier] < 1
3542
+
3543
+ id = sub[:id]
1824
3544
 
1825
3545
  # If sub surface type is invalid, log/reset. Additional corrections may
1826
3546
  # be enabled once a sub surface is actually instantiated.
@@ -1855,14 +3575,17 @@ module OSut
1855
3575
  end
1856
3576
  end
1857
3577
 
1858
- # Log/reset negative numerical values. Set ~0 values to 0.
3578
+ # Log/reset negative float values. Set ~0.0 values to 0.0.
1859
3579
  sub.each do |key, value|
1860
- next if key == :id
3580
+ next if key == :count
3581
+ next if key == :multiplier
1861
3582
  next if key == :type
3583
+ next if key == :id
1862
3584
  next if key == :frame
1863
3585
  next if key == :assembly
1864
3586
 
1865
- return mismatch(key, value, cl5, mth, DBG, no) unless value.is_a?(cl5)
3587
+ ok = value.respond_to?(:to_f)
3588
+ return mismatch(key, value, Float, mth, DBG, no) unless ok
1866
3589
  next if key == :centreline
1867
3590
 
1868
3591
  negative(key, mth, WRN) if value < 0
@@ -1907,9 +3630,9 @@ module OSut
1907
3630
  max_height = max_y - buffers
1908
3631
  max_width = max_x - buffers
1909
3632
 
1910
- # Default sub surface "head" & "sill" height (unless user-specified).
1911
- typ_head = HEAD # standard 80" door
1912
- typ_sill = SILL # standard 30" window sill
3633
+ # Default sub surface "head" & "sill" height, unless user-specified.
3634
+ typ_head = HEAD
3635
+ typ_sill = SILL
1913
3636
 
1914
3637
  if sub.key?(:ratio)
1915
3638
  typ_head = mid_y * (1 + sub[:ratio]) if sub[:ratio] > 0.75
@@ -2051,18 +3774,22 @@ module OSut
2051
3774
  # Log/reset "width" if beyond min/max.
2052
3775
  if sub.key?(:width)
2053
3776
  unless sub[:width].between?(glass, max_width)
2054
- sub[:width] = glass if sub[:width] < glass
2055
- sub[:width] = max_width if sub[:width] > max_width
3777
+ sub[:width] = glass if sub[:width] < glass
3778
+ sub[:width] = max_width if sub[:width] > max_width
2056
3779
  log(WRN, "Reset '#{id}' width to #{sub[:width]} m (#{mth})")
2057
3780
  end
2058
3781
  end
2059
3782
 
2060
- # Log/reset "count" if < 1.
2061
- if sub.key?(:count)
3783
+ # Log/reset "count" if < 1 (or not an Integer)
3784
+ if sub[:count].respond_to?(:to_i)
3785
+ sub[:count] = sub[:count].to_i
3786
+
2062
3787
  if sub[:count] < 1
2063
3788
  sub[:count] = 1
2064
3789
  log(WRN, "Reset '#{id}' count to #{sub[:count]} (#{mth})")
2065
3790
  end
3791
+ else
3792
+ sub[:count] = 1
2066
3793
  end
2067
3794
 
2068
3795
  sub[:count] = 1 unless sub.key?(:count)
@@ -2221,7 +3948,7 @@ module OSut
2221
3948
 
2222
3949
  # Generate sub(s).
2223
3950
  sub[:count].times do |i|
2224
- name = "#{id}:#{i}"
3951
+ name = "#{id}|#{i}"
2225
3952
  fr = 0
2226
3953
  fr = sub[:frame].frameWidth if sub[:frame]
2227
3954
 
@@ -2230,12 +3957,12 @@ module OSut
2230
3957
  vec << OpenStudio::Point3d.new(pos, sub[:sill], 0)
2231
3958
  vec << OpenStudio::Point3d.new(pos + sub[:width], sub[:sill], 0)
2232
3959
  vec << OpenStudio::Point3d.new(pos + sub[:width], sub[:head], 0)
2233
- vec = tr * vec
3960
+ vec = t * vec
2234
3961
 
2235
3962
  # Log/skip if conflict between individual sub and base surface.
2236
3963
  vc = vec
2237
3964
  vc = offset(vc, fr, 300) if fr > 0
2238
- ok = fits?(vc, s.vertices, name, nom)
3965
+ ok = fits?(vc, s)
2239
3966
  log(ERR, "Skip '#{name}': won't fit in '#{nom}' (#{mth})") unless ok
2240
3967
  break unless ok
2241
3968
 
@@ -2247,7 +3974,7 @@ module OSut
2247
3974
  fr = fd.get.frameWidth unless fd.empty?
2248
3975
  vk = sb.vertices
2249
3976
  vk = offset(vk, fr, 300) if fr > 0
2250
- oops = overlaps?(vc, vk, name, nome)
3977
+ oops = overlaps?(vc, vk)
2251
3978
  log(ERR, "Skip '#{name}': overlaps '#{nome}' (#{mth})") if oops
2252
3979
  ok = false if oops
2253
3980
  break if oops
@@ -2255,7 +3982,7 @@ module OSut
2255
3982
 
2256
3983
  break unless ok
2257
3984
 
2258
- sb = OpenStudio::Model::SubSurface.new(vec, model)
3985
+ sb = OpenStudio::Model::SubSurface.new(vec, mdl)
2259
3986
  sb.setName(name)
2260
3987
  sb.setSubSurfaceType(sub[:type])
2261
3988
  sb.setConstruction(sub[:assembly]) if sub[:assembly]