tbd 3.2.3 → 3.3.0

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