osut 0.2.8 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
data/lib/osut/utils.rb CHANGED
@@ -31,21 +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
-
45
- # This first set of utilities (~750 lines) help distinguishing spaces that
46
- # 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
47
1119
  # relies as much as possible on space conditioning categories found in
48
- # 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
+ #
49
1122
  # Both documents share many similarities, regardless of nomenclature. There
50
1123
  # are however noticeable differences between approaches on how a space is
51
1124
  # tagged as falling into one of the aforementioned categories. First, an
@@ -67,11 +1140,11 @@ module OSut
67
1140
  #
68
1141
  # ... includes plenums, atria, etc.
69
1142
  #
70
- # - SEMI-HEATED space: an ENCLOSED space that has a heating system
1143
+ # - SEMIHEATED space: an ENCLOSED space that has a heating system
71
1144
  # >= 10 W/m2, yet NOT a CONDITIONED space (see above).
72
1145
  #
73
1146
  # - UNCONDITIONED space: an ENCLOSED space that is NOT a conditioned
74
- # space or a SEMI-HEATED space (see above).
1147
+ # space or a SEMIHEATED space (see above).
75
1148
  #
76
1149
  # NOTE: Crawlspaces, attics, and parking garages with natural or
77
1150
  # mechanical ventilation are considered UNENCLOSED spaces.
@@ -92,41 +1165,116 @@ module OSut
92
1165
  # to ASHRAE 90.1 (e.g. heating and/or cooling based, no distinction for
93
1166
  # INDIRECTLY conditioned spaces like plenums).
94
1167
  #
95
- # SEMI-HEATED spaces are also a defined NECB term, but again the distinction
96
- # is based on desired/intended design space setpoint temperatures - not
97
- # system sizing criteria. No further treatment is implemented here to
98
- # 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).
99
1175
  #
100
1176
  # The single NECB criterion distinguishing UNCONDITIONED ENCLOSED spaces
101
1177
  # (such as vestibules) from UNENCLOSED spaces (such as attics) remains the
102
1178
  # intention to ventilate - or rather to what degree. Regardless, the methods
103
- # here are designed to process both classifications in the same way, namely by
104
- # focusing on adjacent surfaces to CONDITIONED (or SEMI-HEATED) spaces as part
105
- # of the building envelope.
106
-
107
- # In light of the above, methods here are designed without a priori knowledge
108
- # of explicit system sizing choices or access to iterative autosizing
109
- # processes. As discussed in greater detail elswhere, methods are developed to
110
- # 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.
111
1190
  #
112
1191
  # For an OpenStudio model in an incomplete or preliminary state, e.g. holding
113
- # fully-formed ENCLOSED spaces without thermal zoning information or setpoint
114
- # temperatures (early design stage assessments of form, porosity or envelope),
115
- # all OpenStudio spaces will be considered CONDITIONED, presuming setpoints of
116
- # ~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
117
1254
  #
118
- # If ANY valid space/zone-specific temperature setpoints are found in the
119
- # OpenStudio model, spaces/zones WITHOUT valid heating or cooling setpoints
120
- # are considered as UNCONDITIONED or UNENCLOSED spaces (like attics), or
121
- # 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
122
1270
 
123
1271
  ##
124
- # Return min & max values of a schedule (ruleset).
1272
+ # Returns MIN/MAX values of a schedule (ruleset).
125
1273
  #
126
- # @param sched [OpenStudio::Model::ScheduleRuleset] schedule
1274
+ # @param sched [OpenStudio::Model::ScheduleRuleset] a schedule
127
1275
  #
128
1276
  # @return [Hash] min: (Float), max: (Float)
129
- # @return [Hash] min: nil, max: nil (if invalid input)
1277
+ # @return [Hash] min: nil, max: nil if invalid inputs (see logs)
130
1278
  def scheduleRulesetMinMax(sched = nil)
131
1279
  # Largely inspired from David Goldwasser's
132
1280
  # "schedule_ruleset_annual_min_max_value":
@@ -137,44 +1285,28 @@ module OSut
137
1285
  mth = "OSut::#{__callee__}"
138
1286
  cl = OpenStudio::Model::ScheduleRuleset
139
1287
  res = { min: nil, max: nil }
140
-
141
- 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)
142
1289
 
143
1290
  id = sched.nameString
144
1291
  return mismatch(id, sched, cl, mth, DBG, res) unless sched.is_a?(cl)
145
1292
 
146
- profiles = []
147
- profiles << sched.defaultDaySchedule
148
- sched.scheduleRules.each { |rule| profiles << rule.daySchedule }
1293
+ values = sched.defaultDaySchedule.values.to_a
149
1294
 
150
- profiles.each do |profile|
151
- id = profile.nameString
1295
+ sched.scheduleRules.each { |rule| values += rule.daySchedule.values }
152
1296
 
153
- profile.values.each do |val|
154
- ok = val.is_a?(Numeric)
155
- log(WRN, "Skipping non-numeric value in '#{id}' (#{mth})") unless ok
156
- next unless ok
157
-
158
- res[:min] = val unless res[:min]
159
- res[:min] = val if res[:min] > val
160
- res[:max] = val unless res[:max]
161
- res[:max] = val if res[:max] < val
162
- end
163
- end
164
-
165
- valid = res[:min] && res[:max]
166
- 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
167
1299
 
168
1300
  res
169
1301
  end
170
1302
 
171
1303
  ##
172
- # Return min & max values of a schedule (constant).
1304
+ # Returns MIN/MAX values of a schedule (constant).
173
1305
  #
174
- # @param sched [OpenStudio::Model::ScheduleConstant] schedule
1306
+ # @param sched [OpenStudio::Model::ScheduleConstant] a schedule
175
1307
  #
176
1308
  # @return [Hash] min: (Float), max: (Float)
177
- # @return [Hash] min: nil, max: nil (if invalid input)
1309
+ # @return [Hash] min: nil, max: nil if invalid inputs (see logs)
178
1310
  def scheduleConstantMinMax(sched = nil)
179
1311
  # Largely inspired from David Goldwasser's
180
1312
  # "schedule_constant_annual_min_max_value":
@@ -185,14 +1317,13 @@ module OSut
185
1317
  mth = "OSut::#{__callee__}"
186
1318
  cl = OpenStudio::Model::ScheduleConstant
187
1319
  res = { min: nil, max: nil }
188
-
189
- 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)
190
1321
 
191
1322
  id = sched.nameString
192
1323
  return mismatch(id, sched, cl, mth, DBG, res) unless sched.is_a?(cl)
193
1324
 
194
- valid = sched.value.is_a?(Numeric)
195
- 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
196
1327
  res[:min] = sched.value
197
1328
  res[:max] = sched.value
198
1329
 
@@ -200,12 +1331,12 @@ module OSut
200
1331
  end
201
1332
 
202
1333
  ##
203
- # Return min & max values of a schedule (compact).
1334
+ # Returns MIN/MAX values of a schedule (compact).
204
1335
  #
205
1336
  # @param sched [OpenStudio::Model::ScheduleCompact] schedule
206
1337
  #
207
1338
  # @return [Hash] min: (Float), max: (Float)
208
- # @return [Hash] min: nil, max: nil (if invalid input)
1339
+ # @return [Hash] min: nil, max: nil if invalid input (see logs)
209
1340
  def scheduleCompactMinMax(sched = nil)
210
1341
  # Largely inspired from Andrew Parker's
211
1342
  # "schedule_compact_annual_min_max_value":
@@ -213,78 +1344,69 @@ module OSut
213
1344
  # github.com/NREL/openstudio-standards/blob/
214
1345
  # 99cf713750661fe7d2082739f251269c2dfd9140/lib/openstudio-standards/
215
1346
  # standards/Standards.ScheduleCompact.rb#L8
216
- mth = "OSut::#{__callee__}"
217
- cl = OpenStudio::Model::ScheduleCompact
218
- vals = []
219
- prev_str = ""
220
- res = { min: nil, max: nil }
221
-
222
- 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)
223
1353
 
224
1354
  id = sched.nameString
225
1355
  return mismatch(id, sched, cl, mth, DBG, res) unless sched.is_a?(cl)
226
1356
 
227
1357
  sched.extensibleGroups.each do |eg|
228
- if prev_str.include?("until")
1358
+ if prev.include?("until")
229
1359
  vals << eg.getDouble(0).get unless eg.getDouble(0).empty?
230
1360
  end
231
1361
 
232
- str = eg.getString(0)
233
- prev_str = str.get.downcase unless str.empty?
1362
+ str = eg.getString(0)
1363
+ prev = str.get.downcase unless str.empty?
234
1364
  end
235
1365
 
236
- return empty("'#{id}' values", mth, ERR, res) if vals.empty?
1366
+ return empty("#{id} values", mth, ERR, res) if vals.empty?
237
1367
 
238
- ok = vals.min.is_a?(Numeric) && vals.max.is_a?(Numeric)
239
- log(ERR, "Non-numeric values in '#{id}' (#{mth})") unless ok
240
- return res unless ok
241
-
242
- res[:min] = vals.min
243
- 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
244
1370
 
245
1371
  res
246
1372
  end
247
1373
 
248
1374
  ##
249
- # Return min & max values for schedule (interval).
1375
+ # Returns MIN/MAX values for schedule (interval).
250
1376
  #
251
1377
  # @param sched [OpenStudio::Model::ScheduleInterval] schedule
252
1378
  #
253
1379
  # @return [Hash] min: (Float), max: (Float)
254
- # @return [Hash] min: nil, max: nil (if invalid input)
1380
+ # @return [Hash] min: nil, max: nil if invalid input (see logs)
255
1381
  def scheduleIntervalMinMax(sched = nil)
256
1382
  mth = "OSut::#{__callee__}"
257
1383
  cl = OpenStudio::Model::ScheduleInterval
258
1384
  vals = []
259
1385
  res = { min: nil, max: nil }
260
-
261
- 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)
262
1387
 
263
1388
  id = sched.nameString
264
1389
  return mismatch(id, sched, cl, mth, DBG, res) unless sched.is_a?(cl)
265
1390
 
266
1391
  vals = sched.timeSeries.values
267
- ok = vals.min.is_a?(Numeric) && vals.max.is_a?(Numeric)
268
- log(ERR, "Non-numeric values in '#{id}' (#{mth})") unless ok
269
- return res unless ok
270
1392
 
271
- res[:min] = vals.min
272
- 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
273
1395
 
274
1396
  res
275
1397
  end
276
1398
 
277
1399
  ##
278
- # Return max zone heating temperature schedule setpoint [°C] and whether
279
- # 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.
280
1402
  #
281
1403
  # @param zone [OpenStudio::Model::ThermalZone] a thermal zone
282
1404
  #
283
1405
  # @return [Hash] spt: (Float), dual: (Bool)
284
- # @return [Hash] spt: nil, dual: false (if invalid input)
1406
+ # @return [Hash] spt: nil, dual: false if invalid input (see logs)
285
1407
  def maxHeatScheduledSetpoint(zone = nil)
286
1408
  # Largely inspired from Parker & Marrec's "thermal_zone_heated?" procedure.
287
- # 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
288
1410
  # per Canadian NECB criteria (basically any space with at least 10 W/m2 of
289
1411
  # installed heating equipement, i.e. below freezing in Canada).
290
1412
  #
@@ -294,8 +1416,7 @@ module OSut
294
1416
  mth = "OSut::#{__callee__}"
295
1417
  cl = OpenStudio::Model::ThermalZone
296
1418
  res = { spt: nil, dual: false }
297
-
298
- 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)
299
1420
 
300
1421
  id = zone.nameString
301
1422
  return mismatch(id, zone, cl, mth, DBG, res) unless zone.is_a?(cl)
@@ -378,8 +1499,8 @@ module OSut
378
1499
 
379
1500
  return res if zone.thermostat.empty?
380
1501
 
381
- tstat = zone.thermostat.get
382
- res[:spt] = nil
1502
+ tstat = zone.thermostat.get
1503
+ res[:spt] = nil
383
1504
 
384
1505
  unless tstat.to_ThermostatSetpointDualSetpoint.empty? &&
385
1506
  tstat.to_ZoneControlThermostatStagedDualSetpoint.empty?
@@ -392,7 +1513,7 @@ module OSut
392
1513
 
393
1514
  unless tstat.heatingSetpointTemperatureSchedule.empty?
394
1515
  res[:dual] = true
395
- sched = tstat.heatingSetpointTemperatureSchedule.get
1516
+ sched = tstat.heatingSetpointTemperatureSchedule.get
396
1517
 
397
1518
  unless sched.to_ScheduleRuleset.empty?
398
1519
  sched = sched.to_ScheduleRuleset.get
@@ -451,16 +1572,15 @@ module OSut
451
1572
  end
452
1573
 
453
1574
  ##
454
- # Validate if model has zones with valid heating temperature setpoints.
1575
+ # Validates if model has zones with valid heating temperature setpoints.
455
1576
  #
456
1577
  # @param model [OpenStudio::Model::Model] a model
457
1578
  #
458
- # @return [Bool] true if valid heating temperature setpoints
459
- # @return [Bool] false if invalid input
1579
+ # @return [Bool] whether model holds valid heating temperature setpoints
1580
+ # @return [false] if invalid input (see logs)
460
1581
  def heatingTemperatureSetpoints?(model = nil)
461
1582
  mth = "OSut::#{__callee__}"
462
1583
  cl = OpenStudio::Model::Model
463
-
464
1584
  return mismatch("model", model, cl, mth, DBG, false) unless model.is_a?(cl)
465
1585
 
466
1586
  model.getThermalZones.each do |zone|
@@ -471,13 +1591,13 @@ module OSut
471
1591
  end
472
1592
 
473
1593
  ##
474
- # Return min zone cooling temperature schedule setpoint [°C] and whether
475
- # 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.
476
1596
  #
477
1597
  # @param zone [OpenStudio::Model::ThermalZone] a thermal zone
478
1598
  #
479
1599
  # @return [Hash] spt: (Float), dual: (Bool)
480
- # @return [Hash] spt: nil, dual: false (if invalid input)
1600
+ # @return [Hash] spt: nil, dual: false if invalid input (see logs)
481
1601
  def minCoolScheduledSetpoint(zone = nil)
482
1602
  # Largely inspired from Parker & Marrec's "thermal_zone_cooled?" procedure.
483
1603
  #
@@ -487,8 +1607,7 @@ module OSut
487
1607
  mth = "OSut::#{__callee__}"
488
1608
  cl = OpenStudio::Model::ThermalZone
489
1609
  res = { spt: nil, dual: false }
490
-
491
- 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)
492
1611
 
493
1612
  id = zone.nameString
494
1613
  return mismatch(id, zone, cl, mth, DBG, res) unless zone.is_a?(cl)
@@ -558,8 +1677,8 @@ module OSut
558
1677
 
559
1678
  return res if zone.thermostat.empty?
560
1679
 
561
- tstat = zone.thermostat.get
562
- res[:spt] = nil
1680
+ tstat = zone.thermostat.get
1681
+ res[:spt] = nil
563
1682
 
564
1683
  unless tstat.to_ThermostatSetpointDualSetpoint.empty? &&
565
1684
  tstat.to_ZoneControlThermostatStagedDualSetpoint.empty?
@@ -572,7 +1691,7 @@ module OSut
572
1691
 
573
1692
  unless tstat.coolingSetpointTemperatureSchedule.empty?
574
1693
  res[:dual] = true
575
- sched = tstat.coolingSetpointTemperatureSchedule.get
1694
+ sched = tstat.coolingSetpointTemperatureSchedule.get
576
1695
 
577
1696
  unless sched.to_ScheduleRuleset.empty?
578
1697
  sched = sched.to_ScheduleRuleset.get
@@ -631,17 +1750,16 @@ module OSut
631
1750
  end
632
1751
 
633
1752
  ##
634
- # Validate if model has zones with valid cooling temperature setpoints.
1753
+ # Validates if model has zones with valid cooling temperature setpoints.
635
1754
  #
636
1755
  # @param model [OpenStudio::Model::Model] a model
637
1756
  #
638
- # @return [Bool] true if valid cooling temperature setpoints
639
- # @return [Bool] false if invalid input
1757
+ # @return [Bool] whether model holds valid cooling temperature setpoints
1758
+ # @return [false] if invalid input (see logs)
640
1759
  def coolingTemperatureSetpoints?(model = nil)
641
1760
  mth = "OSut::#{__callee__}"
642
1761
  cl = OpenStudio::Model::Model
643
-
644
- 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)
645
1763
 
646
1764
  model.getThermalZones.each do |zone|
647
1765
  return true if minCoolScheduledSetpoint(zone)[:spt]
@@ -651,117 +1769,318 @@ module OSut
651
1769
  end
652
1770
 
653
1771
  ##
654
- # Validate if model has zones with HVAC air loops.
1772
+ # Validates whether space is a vestibule.
655
1773
  #
656
- # @param model [OpenStudio::Model::Model] a model
1774
+ # @param space [OpenStudio::Model::Space] a space
657
1775
  #
658
- # @return [Bool] true if model has one or more HVAC air loops
659
- # @return [Bool] false if invalid input
660
- 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.
661
1811
  mth = "OSut::#{__callee__}"
662
- cl = OpenStudio::Model::Model
1812
+ cl = OpenStudio::Model::Space
1813
+ return mismatch("space", space, cl, mth, DBG, false) unless space.is_a?(cl)
663
1814
 
664
- 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"
665
1818
 
666
- model.getThermalZones.each do |zone|
667
- next if zone.canBePlenum
668
- return true unless zone.airLoopHVACs.empty?
669
- 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
670
1838
  end
671
1839
 
672
1840
  false
673
1841
  end
674
1842
 
675
1843
  ##
676
- # Validate whether space should be processed as a plenum.
1844
+ # Validates whether a space is an indirectly-conditioned plenum.
677
1845
  #
678
1846
  # @param space [OpenStudio::Model::Space] a space
679
- # @param loops [Bool] true if model has airLoopHVAC object(s)
680
- # @param setpoints [Bool] true if model has valid temperature setpoints
681
1847
  #
682
- # @return [Bool] true if should be tagged as plenum
683
- # @return [Bool] false if invalid input
684
- def plenum?(space = nil, loops = nil, setpoints = nil)
685
- # 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?":
686
1852
  #
687
- # github.com/NREL/openstudio-standards/blob/
688
- # 58964222d25783e9da4ae292e375fb0d5c902aa5/lib/openstudio-standards/
689
- # 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").
690
1892
  #
691
- # A space may be tagged as a plenum if:
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
- # CASE A: its zone's "isPlenum" == true (SDK method) for a fully-developed
694
- # OpenStudio model (complete with HVAC air loops); OR
1899
+ # CASE A: it includes the substring "plenum" (case insensitive) in its
1900
+ # spaceType's name, or in the latter's standardsSpaceType string;
695
1901
  #
696
- # CASE B: (IN ABSENCE OF HVAC AIRLOOPS) if it's excluded from a building's
697
- # total floor area yet linked to a zone holding an 'inactive'
698
- # thermostat, i.e. can't extract valid setpoints; OR
1902
+ # CASE B: "isPlenum" == TRUE in an OpenStudio model WITH HVAC airloops: OR
699
1903
  #
700
- # CASE C: (IN ABSENCE OF HVAC AIRLOOPS & VALID SETPOINTS) it has "plenum"
701
- # (case insensitive) as a spacetype (or as a spacetype's
702
- # 'standards spacetype').
1904
+ # CASE C: its zone holds an 'inactive' thermostat (i.e. can't extract valid
1905
+ # setpoints) in an OpenStudio model with setpoint temperatures.
1906
+ #
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
703
1912
  mth = "OSut::#{__callee__}"
704
1913
  cl = OpenStudio::Model::Space
705
-
706
- 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)
707
1917
 
708
1918
  id = space.nameString
709
- 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
710
1950
 
711
- valid = loops == true || loops == false
712
- return invalid("loops", mth, 2, DBG, false) unless valid
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
1980
+
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
713
1989
 
714
- valid = setpoints == true || setpoints == false
715
- 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)
716
1993
 
717
- unless space.thermalZone.empty?
718
- zone = space.thermalZone.get
719
- return zone.isPlenum if loops # A
1994
+ unless id.empty?
1995
+ id = id.get
1996
+ dad = space.model.getSpaceByName(id)
720
1997
 
721
- if setpoints
722
- heat = maxHeatScheduledSetpoint(zone)
723
- cool = minCoolScheduledSetpoint(zone)
724
- return false if heat[:spt] || cool[:spt] # directly conditioned
725
- return heat[:dual] || cool[:dual] unless space.partofTotalFloorArea # B
726
- 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
727
2005
  end
728
2006
  end
729
2007
 
730
- unless space.spaceType.empty?
731
- type = space.spaceType.get
732
- 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]
733
2019
  end
734
2020
 
735
- unless type.standardsSpaceType.empty?
736
- type = type.standardsSpaceType.get
737
- return type.downcase == "plenum" # C
738
- end
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
2034
+ end
2035
+
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)
739
2056
 
740
- false
2057
+ ok = false
2058
+ ok = setpoints(space)[:heating].nil? && setpoints(space)[:cooling].nil?
2059
+
2060
+ ok
741
2061
  end
742
2062
 
743
2063
  ##
744
- # Generate an HVAC availability schedule.
2064
+ # Generates an HVAC availability schedule.
745
2065
  #
746
2066
  # @param model [OpenStudio::Model::Model] a model
747
2067
  # @param avl [String] seasonal availability choice (optional, default "ON")
748
2068
  #
749
2069
  # @return [OpenStudio::Model::Schedule] HVAC availability sched
750
- # @return [NilClass] if invalid input
2070
+ # @return [nil] if invalid input (see logs)
751
2071
  def availabilitySchedule(model = nil, avl = "")
752
2072
  mth = "OSut::#{__callee__}"
753
2073
  cl = OpenStudio::Model::Model
754
2074
  limits = nil
755
-
756
- return mismatch("model", model, cl, mth) unless model.is_a?(cl)
757
- 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)
758
2077
 
759
2078
  # Either fetch availability ScheduleTypeLimits object, or create one.
760
2079
  model.getScheduleTypeLimitss.each do |l|
761
- break if limits
762
- next if l.lowerLimitValue.empty?
763
- next if l.upperLimitValue.empty?
764
- 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?
765
2084
  next unless l.lowerLimitValue.get.to_i == 0
766
2085
  next unless l.upperLimitValue.get.to_i == 1
767
2086
  next unless l.numericType.get.downcase == "discrete"
@@ -787,35 +2106,35 @@ module OSut
787
2106
 
788
2107
  # Seasonal availability start/end dates.
789
2108
  year = model.yearDescription
790
- return empty("yearDescription", mth, ERR) if year.empty?
2109
+ return empty("yearDescription", mth, ERR) if year.empty?
791
2110
 
792
2111
  year = year.get
793
2112
  may01 = year.makeDate(OpenStudio::MonthOfYear.new("May"), 1)
794
2113
  oct31 = year.makeDate(OpenStudio::MonthOfYear.new("Oct"), 31)
795
2114
 
796
- case avl.to_s.downcase
797
- 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)
798
2117
  val = 1
799
2118
  sch = off
800
2119
  nom = "WINTER Availability SchedRuleset"
801
2120
  dft = "WINTER Availability dftDaySched"
802
2121
  tag = "May-Oct WINTER Availability SchedRule"
803
2122
  day = "May-Oct WINTER SchedRule Day"
804
- when "summer" # available from May 1 to October 31 (6 months)
2123
+ when "summer" # available from May 1 to October 31 (6 months)
805
2124
  val = 0
806
2125
  sch = on
807
2126
  nom = "SUMMER Availability SchedRuleset"
808
2127
  dft = "SUMMER Availability dftDaySched"
809
2128
  tag = "May-Oct SUMMER Availability SchedRule"
810
2129
  day = "May-Oct SUMMER SchedRule Day"
811
- when "off" # never available
2130
+ when "off" # never available
812
2131
  val = 0
813
2132
  sch = on
814
2133
  nom = "OFF Availability SchedRuleset"
815
2134
  dft = "OFF Availability dftDaySched"
816
2135
  tag = ""
817
2136
  day = ""
818
- else # always available
2137
+ else # always available
819
2138
  val = 1
820
2139
  sch = on
821
2140
  nom = "ON Availability SchedRuleset"
@@ -833,14 +2152,14 @@ module OSut
833
2152
 
834
2153
  unless schedule.empty?
835
2154
  schedule = schedule.get
836
- default = schedule.defaultDaySchedule
2155
+ default = schedule.defaultDaySchedule
837
2156
  ok = ok && default.nameString == dft
838
2157
  ok = ok && default.times.size == 1
839
2158
  ok = ok && default.values.size == 1
840
2159
  ok = ok && default.times.first == time
841
2160
  ok = ok && default.values.first == val
842
2161
  rules = schedule.scheduleRules
843
- ok = ok && (rules.size == 0 || rules.size == 1)
2162
+ ok = ok && rules.size < 2
844
2163
 
845
2164
  if rules.size == 1
846
2165
  rule = rules.first
@@ -865,30 +2184,37 @@ module OSut
865
2184
 
866
2185
  schedule = OpenStudio::Model::ScheduleRuleset.new(model)
867
2186
  schedule.setName(nom)
868
- ok = schedule.setScheduleTypeLimits(limits)
869
- log(ERR, "'#{nom}': Can't set schedule type limits (#{mth})") unless ok
870
- return nil unless ok
871
2187
 
872
- ok = schedule.defaultDaySchedule.addValue(time, val)
873
- log(ERR, "'#{nom}': Can't set default day schedule (#{mth})") unless ok
874
- 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
875
2197
 
876
2198
  schedule.defaultDaySchedule.setName(dft)
877
2199
 
878
2200
  unless tag.empty?
879
2201
  rule = OpenStudio::Model::ScheduleRule.new(schedule, sch)
880
2202
  rule.setName(tag)
881
- ok = rule.setStartDate(may01)
882
- log(ERR, "'#{tag}': Can't set start date (#{mth})") unless ok
883
- return nil unless ok
884
2203
 
885
- ok = rule.setEndDate(oct31)
886
- log(ERR, "'#{tag}': Can't set end date (#{mth})") unless ok
887
- 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
888
2213
 
889
- ok = rule.setApplyAllDays(true)
890
- log(ERR, "'#{tag}': Can't apply to all days (#{mth})") unless ok
891
- 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
892
2218
 
893
2219
  rule.daySchedule.setName(day)
894
2220
  end
@@ -896,612 +2222,756 @@ module OSut
896
2222
  schedule
897
2223
  end
898
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
+
899
2238
  ##
900
- # Validate if default construction set holds a base construction.
2239
+ # Returns OpenStudio site/space transformation & rotation angle [0,2PI) rads.
901
2240
  #
902
- # @param set [OpenStudio::Model::DefaultConstructionSet] a default set
903
- # @param bse [OpensStudio::Model::ConstructionBase] a construction base
904
- # @param gr [Bool] true if ground-facing surface
905
- # @param ex [Bool] true if exterior-facing surface
906
- # @param typ [String] a surface type
2241
+ # @param group [OpenStudio::Model::PlanarSurfaceGroup] a site or space object
907
2242
  #
908
- # @return [Bool] true if default construction set holds construction
909
- # @return [Bool] false if invalid input
910
- 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)
911
2246
  mth = "OSut::#{__callee__}"
912
- cl1 = OpenStudio::Model::DefaultConstructionSet
913
- cl2 = OpenStudio::Model::ConstructionBase
914
-
915
- return invalid("set", mth, 1, DBG, false) unless set.respond_to?(NS)
916
-
917
- id = set.nameString
918
- return mismatch(id, set, cl1, mth, DBG, false) unless set.is_a?(cl1)
919
- return invalid("base", mth, 2, DBG, false) unless bse.respond_to?(NS)
920
-
921
- id = bse.nameString
922
- return mismatch(id, bse, cl2, mth, DBG, false) unless bse.is_a?(cl2)
923
-
924
- valid = gr == true || gr == false
925
- return invalid("ground", mth, 3, DBG, false) unless valid
926
-
927
- valid = ex == true || ex == false
928
- return invalid("exterior", mth, 4, DBG, false) unless valid
929
-
930
- valid = typ.respond_to?(:to_s)
931
- return invalid("surface typ", mth, 4, DBG, false) unless valid
932
-
933
- type = typ.to_s.downcase
934
- valid = type == "floor" || type == "wall" || type == "roofceiling"
935
- return invalid("surface type", mth, 5, DBG, false) unless valid
936
-
937
- constructions = nil
938
-
939
- if gr
940
- unless set.defaultGroundContactSurfaceConstructions.empty?
941
- constructions = set.defaultGroundContactSurfaceConstructions.get
942
- end
943
- elsif ex
944
- unless set.defaultExteriorSurfaceConstructions.empty?
945
- constructions = set.defaultExteriorSurfaceConstructions.get
946
- end
947
- else
948
- unless set.defaultInteriorSurfaceConstructions.empty?
949
- constructions = set.defaultInteriorSurfaceConstructions.get
950
- end
951
- end
2247
+ cl2 = OpenStudio::Model::PlanarSurfaceGroup
2248
+ res = { t: nil, r: nil }
2249
+ return invalid("group", mth, 2, DBG, res) unless group.respond_to?(NS)
952
2250
 
953
- return false unless constructions
2251
+ id = group.nameString
2252
+ mdl = group.model
2253
+ return mismatch(id, group, cl2, mth, DBG, res) unless group.is_a?(cl2)
954
2254
 
955
- case type
956
- when "roofceiling"
957
- unless constructions.roofCeilingConstruction.empty?
958
- construction = constructions.roofCeilingConstruction.get
959
- return true if construction == bse
960
- end
961
- when "floor"
962
- unless constructions.floorConstruction.empty?
963
- construction = constructions.floorConstruction.get
964
- return true if construction == bse
965
- end
966
- else
967
- unless constructions.wallConstruction.empty?
968
- construction = constructions.wallConstruction.get
969
- return true if construction == bse
970
- end
971
- end
2255
+ res[:t] = group.siteTransformation
2256
+ res[:r] = group.directionofRelativeNorth + mdl.getBuilding.northAxis
972
2257
 
973
- false
2258
+ res
974
2259
  end
975
2260
 
976
2261
  ##
977
- # Return a surface's default construction set.
2262
+ # Returns true if 2 OpenStudio 3D points are nearly equal
978
2263
  #
979
- # @param model [OpenStudio::Model::Model] a model
980
- # @param s [OpenStudio::Model::Surface] a surface
2264
+ # @param p1 [OpenStudio::Point3d] 1st 3D point
2265
+ # @param p2 [OpenStudio::Point3d] 2nd 3D point
981
2266
  #
982
- # @return [OpenStudio::Model::DefaultConstructionSet] default set
983
- # @return [NilClass] if invalid input
984
- def defaultConstructionSet(model = nil, s = nil)
2267
+ # @return [Bool] whether equal points (within TOL)
2268
+ # @return [false] if invalid input (see logs)
2269
+ def same?(p1 = nil, p2 = nil)
985
2270
  mth = "OSut::#{__callee__}"
986
- cl1 = OpenStudio::Model::Model
987
- cl2 = OpenStudio::Model::Surface
988
-
989
- return mismatch("model", model, cl1, mth) unless model.is_a?(cl1)
990
- return invalid("s", mth, 2) unless s.respond_to?(NS)
991
-
992
- id = s.nameString
993
- return mismatch(id, s, cl2, mth) unless s.is_a?(cl2)
994
-
995
- ok = s.isConstructionDefaulted
996
- log(ERR, "'#{id}' construction not defaulted (#{mth})") unless ok
997
- return nil unless ok
998
- return empty("'#{id}' construction", mth, ERR) if s.construction.empty?
999
-
1000
- base = s.construction.get
1001
- return empty("'#{id}' space", mth, ERR) if s.space.empty?
1002
-
1003
- space = s.space.get
1004
- type = s.surfaceType
1005
- ground = false
1006
- exterior = false
1007
-
1008
- if s.isGroundSurface
1009
- ground = true
1010
- elsif s.outsideBoundaryCondition.downcase == "outdoors"
1011
- exterior = true
1012
- end
1013
-
1014
- unless space.defaultConstructionSet.empty?
1015
- set = space.defaultConstructionSet.get
1016
- return set if holdsConstruction?(set, base, ground, exterior, type)
1017
- end
1018
-
1019
- unless space.spaceType.empty?
1020
- spacetype = space.spaceType.get
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)
1021
2274
 
1022
- unless spacetype.defaultConstructionSet.empty?
1023
- set = spacetype.defaultConstructionSet.get
1024
- return set if holdsConstruction?(set, base, ground, exterior, type)
1025
- end
1026
- end
1027
-
1028
- unless space.buildingStory.empty?
1029
- story = space.buildingStory.get
1030
-
1031
- unless story.defaultConstructionSet.empty?
1032
- set = story.defaultConstructionSet.get
1033
- return set if holdsConstruction?(set, base, ground, exterior, type)
1034
- end
1035
- end
1036
-
1037
- building = model.getBuilding
1038
-
1039
- unless building.defaultConstructionSet.empty?
1040
- set = building.defaultConstructionSet.get
1041
- return set if holdsConstruction?(set, base, ground, exterior, type)
1042
- end
1043
-
1044
- nil
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
1045
2277
  end
1046
2278
 
1047
2279
  ##
1048
- # Validate if every material in a layered construction is standard & opaque.
2280
+ # Returns true if a line segment is along the X-axis.
1049
2281
  #
1050
- # @param lc [OpenStudio::LayeredConstruction] a layered construction
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
1051
2285
  #
1052
- # @return [Bool] true if all layers are valid
1053
- # @return [Bool] false if invalid input
1054
- def standardOpaqueLayers?(lc = 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)
1055
2289
  mth = "OSut::#{__callee__}"
1056
- cl = OpenStudio::Model::LayeredConstruction
1057
-
1058
- return invalid("lc", mth, 1, DBG, false) unless lc.respond_to?(NS)
1059
- return mismatch(lc.nameString, lc, cl, mth, DBG, false) unless lc.is_a?(cl)
1060
-
1061
- lc.layers.each { |m| return false if m.to_StandardOpaqueMaterial.empty? }
1062
-
1063
- true
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
1064
2298
  end
1065
2299
 
1066
2300
  ##
1067
- # Total (standard opaque) layered construction thickness (in m).
2301
+ # Returns true if a line segment is along the Y-axis.
1068
2302
  #
1069
- # @param lc [OpenStudio::LayeredConstruction] a layered construction
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
1070
2306
  #
1071
- # @return [Float] total layered construction thickness
1072
- # @return [Float] 0 if invalid input
1073
- def thickness(lc = nil)
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)
1074
2310
  mth = "OSut::#{__callee__}"
1075
- cl = OpenStudio::Model::LayeredConstruction
1076
-
1077
- return invalid("lc", mth, 1, DBG, 0.0) unless lc.respond_to?(NS)
1078
-
1079
- id = lc.nameString
1080
- return mismatch(id, lc, cl, mth, DBG, 0.0) unless lc.is_a?(cl)
1081
-
1082
- ok = standardOpaqueLayers?(lc)
1083
- log(ERR, "'#{id}' holds non-StandardOpaqueMaterial(s) (#{mth})") unless ok
1084
- return 0.0 unless ok
1085
-
1086
- thickness = 0.0
1087
- lc.layers.each { |m| thickness += m.thickness }
1088
-
1089
- thickness
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
1090
2319
  end
1091
2320
 
1092
2321
  ##
1093
- # Return total air film resistance for fenestration.
2322
+ # Returns true if a line segment is along the Z-axis.
1094
2323
  #
1095
- # @param usi [Float] a fenestrated construction's U-factor (W/m2•K)
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
1096
2327
  #
1097
- # @return [Float] total air film resistance in m2•K/W (0.1216 if errors)
1098
- def glazingAirFilmRSi(usi = 5.85)
1099
- # The sum of thermal resistances of calculated exterior and interior film
1100
- # coefficients under standard winter conditions are taken from:
1101
- #
1102
- # https://bigladdersoftware.com/epx/docs/9-6/engineering-reference/
1103
- # window-calculation-module.html#simple-window-model
1104
- #
1105
- # These remain acceptable approximations for flat windows, yet likely
1106
- # unsuitable for subsurfaces with curved or projecting shapes like domed
1107
- # skylights. The solution here is considered an adequate fix for reporting,
1108
- # awaiting eventual OpenStudio (and EnergyPlus) upgrades to report NFRC 100
1109
- # (or ISO) air film resistances under standard winter conditions.
1110
- #
1111
- # For U-factors above 8.0 W/m2•K (or invalid input), the function returns
1112
- # 0.1216 m2•K/W, which corresponds to a construction with a single glass
1113
- # layer thickness of 2mm & k = ~0.6 W/m.K.
1114
- #
1115
- # The EnergyPlus Engineering calculations were designed for vertical windows
1116
- # - not horizontal, slanted or domed surfaces - use with caution.
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)
1117
2331
  mth = "OSut::#{__callee__}"
1118
- cl = Numeric
1119
-
1120
- return mismatch("usi", usi, cl, mth, DBG, 0.1216) unless usi.is_a?(cl)
1121
- return invalid("usi", mth, 1, WRN, 0.1216) if usi > 8.0
1122
- return negative("usi", mth, WRN, 0.1216) if usi < 0
1123
- return zero("usi", mth, WRN, 0.1216) if usi.abs < TOL
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
1124
2341
 
1125
- rsi = 1 / (0.025342 * usi + 29.163853) # exterior film, next interior film
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
1126
2355
 
1127
- return rsi + 1 / (0.359073 * Math.log(usi) + 6.949915) if usi < 5.85
1128
- return rsi + 1 / (1.788041 * usi - 2.886625)
2356
+ m = m.to_f
2357
+ OpenStudio::Vector3d.new(m * v.x, m * v.y, m * v.z)
1129
2358
  end
1130
2359
 
1131
2360
  ##
1132
- # Return a construction's 'standard calc' thermal resistance (with air films).
2361
+ # Returns OpenStudio 3D points as an OpenStudio point vector, validating
2362
+ # points in the process (if Array).
1133
2363
  #
1134
- # @param lc [OpenStudio::Model::LayeredConstruction] a layered construction
1135
- # @param film [Float] thermal resistance of surface air films (m2•K/W)
1136
- # @param t [Float] gas temperature (°C) (optional)
2364
+ # @param pts [Set<OpenStudio::Point3d>] OpenStudio 3D points
1137
2365
  #
1138
- # @return [Float] calculated RSi at standard conditions (0 if error)
1139
- def rsi(lc = nil, film = 0.0, t = 0.0)
1140
- # This is adapted from BTAP's Material Module's "get_conductance" (P. Lopez)
1141
- #
1142
- # https://github.com/NREL/OpenStudio-Prototype-Buildings/blob/
1143
- # c3d5021d8b7aef43e560544699fb5c559e6b721d/lib/btap/measures/
1144
- # btap_equest_converter/envelope.rb#L122
2366
+ # @return [OpenStudio::Point3dVector] 3D vector (see logs if empty)
2367
+ def to_p3Dv(pts = nil)
1145
2368
  mth = "OSut::#{__callee__}"
1146
- cl1 = OpenStudio::Model::LayeredConstruction
1147
- cl2 = Numeric
1148
-
1149
- return invalid("lc", mth, 1, DBG, 0.0) unless lc.respond_to?(NS)
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)
1150
2377
 
1151
- id = lc.nameString
2378
+ pts.each do |pt|
2379
+ return mismatch("point", pt, cl4, mth, DBG, v) unless pt.is_a?(cl4)
2380
+ end
1152
2381
 
1153
- return mismatch(id, lc, cl1, mth, DBG, 0.0) unless lc.is_a?(cl1)
1154
- return mismatch("film", film, cl2, mth, DBG, 0.0) unless film.is_a?(cl2)
1155
- return mismatch("temp K", t, cl2, mth, DBG, 0.0) unless t.is_a?(cl2)
2382
+ pts.each { |pt| v << OpenStudio::Point3d.new(pt.x, pt.y, pt.z) }
1156
2383
 
1157
- t += 273.0 # °C to K
1158
- return negative("temp K", mth, DBG, 0.0) if t < 0
1159
- return negative("film", mth, DBG, 0.0) if film < 0
2384
+ v
2385
+ end
1160
2386
 
1161
- rsi = film
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)
1162
2400
 
1163
- lc.layers.each do |m|
1164
- # Fenestration materials first (ignoring shades, screens, etc.)
1165
- empty = m.to_SimpleGlazing.empty?
1166
- return 1 / m.to_SimpleGlazing.get.uFactor unless empty
2401
+ pts.each { |pt| return true if same?(p1, pt) }
1167
2402
 
1168
- empty = m.to_StandardGlazing.empty?
1169
- rsi += m.to_StandardGlazing.get.thermalResistance unless empty
1170
- empty = m.to_RefractionExtinctionGlazing.empty?
1171
- rsi += m.to_RefractionExtinctionGlazing.get.thermalResistance unless empty
1172
- empty = m.to_Gas.empty?
1173
- rsi += m.to_Gas.get.getThermalResistance(t) unless empty
1174
- empty = m.to_GasMixture.empty?
1175
- rsi += m.to_GasMixture.get.getThermalResistance(t) unless empty
2403
+ false
2404
+ end
1176
2405
 
1177
- # Opaque materials next.
1178
- empty = m.to_StandardOpaqueMaterial.empty?
1179
- rsi += m.to_StandardOpaqueMaterial.get.thermalResistance unless empty
1180
- empty = m.to_MasslessOpaqueMaterial.empty?
1181
- rsi += m.to_MasslessOpaqueMaterial.get.thermalResistance unless empty
1182
- empty = m.to_RoofVegetation.empty?
1183
- rsi += m.to_RoofVegetation.get.thermalResistance unless empty
1184
- empty = m.to_AirGap.empty?
1185
- rsi += m.to_AirGap.get.thermalResistance unless empty
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) }
1186
2432
  end
1187
2433
 
1188
- rsi
2434
+ v
1189
2435
  end
1190
2436
 
1191
2437
  ##
1192
- # Identify a layered construction's (opaque) insulating layer. The method
1193
- # returns a 3-keyed hash ... :index (insulating layer index within layered
1194
- # construction), :type (standard: or massless: material type), and
1195
- # :r (material thermal resistance in m2•K/W).
2438
+ # Returns true if OpenStudio 3D points share X, Y or Z coordinates.
1196
2439
  #
1197
- # @param lc [OpenStudio::Model::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
1198
2443
  #
1199
- # @return [Hash] index: (Integer), type: (:standard or :massless), r: (Float)
1200
- # @return [Hash] index: nil, type: nil, r: 0 (if invalid input)
1201
- def insulatingLayer(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)
1202
2447
  mth = "OSut::#{__callee__}"
1203
- cl = OpenStudio::Model::LayeredConstruction
1204
- res = { index: nil, type: nil, r: 0.0 }
1205
- i = 0 # iterator
1206
-
1207
- return invalid("lc", mth, 1, DBG, res) unless lc.respond_to?(NS)
1208
-
1209
- id = lc.nameString
1210
- return mismatch(id, lc, cl1, mth, DBG, res) unless lc.is_a?(cl)
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
1211
2465
 
1212
- lc.layers.each do |m|
1213
- unless m.to_MasslessOpaqueMaterial.empty?
1214
- m = m.to_MasslessOpaqueMaterial.get
2466
+ true
2467
+ end
1215
2468
 
1216
- if m.thermalResistance < 0.001 || m.thermalResistance < res[:r]
1217
- i += 1
1218
- next
1219
- else
1220
- res[:r ] = m.thermalResistance
1221
- res[:index] = i
1222
- res[:type ] = :massless
1223
- end
1224
- end
2469
+ ##
2470
+ # Returns next sequential point in an OpenStudio 3D point vector.
2471
+ #
2472
+ # @param pts [OpenStudio::Point3dVector] 3D points
2473
+ # @param pt [OpenStudio::Point3d] a given 3D point
2474
+ #
2475
+ # @return [OpenStudio::Point3d] the next sequential point
2476
+ # @return [nil] if invalid input (see logs)
2477
+ def next(pts = nil, pt = nil)
2478
+ mth = "OSut::#{__callee__}"
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
1225
2483
 
1226
- unless m.to_StandardOpaqueMaterial.empty?
1227
- m = m.to_StandardOpaqueMaterial.get
1228
- k = m.thermalConductivity
1229
- d = m.thickness
2484
+ pair = pts.each_cons(2).find { |p1, _| same?(p1, pt) }
1230
2485
 
1231
- if d < 0.003 || k > 3.0 || d / k < res[:r]
1232
- i += 1
1233
- next
1234
- else
1235
- res[:r ] = d / k
1236
- res[:index] = i
1237
- res[:type ] = :standard
1238
- end
1239
- end
2486
+ pair.nil? ? pts.first : pair.last
2487
+ end
1240
2488
 
1241
- i += 1
1242
- end
2489
+ ##
2490
+ # Returns unique OpenStudio 3D points from an OpenStudio 3D point vector.
2491
+ #
2492
+ # @param pts [Set<OpenStudio::Point3d] 3D points
2493
+ # @param n [#to_i] requested number of unique points (0 returns all)
2494
+ #
2495
+ # @return [OpenStudio::Point3dVector] unique points (see logs if empty)
2496
+ def getUniques(pts = nil, n = 0)
2497
+ mth = "OSut::#{__callee__}"
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
1243
2503
 
1244
- res
2504
+ pts.each { |pt| v << pt unless holds?(v, pt) }
2505
+
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
2510
+
2511
+ v
1245
2512
  end
1246
2513
 
1247
2514
  ##
1248
- # Return OpenStudio site/space transformation & rotation angle [0,2PI) rads.
2515
+ # Returns sequential non-collinear points in an OpenStudio 3D point vector.
1249
2516
  #
1250
- # @param model [OpenStudio::Model::Model] a model
1251
- # @param group [OpenStudio::Model::PlanarSurfaceGroup] a group
2517
+ # @param pts [Set<OpenStudio::Point3d] 3D points
2518
+ # @param n [#to_i] requested number of non-collinears (0 returns all)
1252
2519
  #
1253
- # @return [Hash] t: (OpenStudio::Transformation), r: Float
1254
- # @return [Hash] t: nil, r: nil (if invalid input)
1255
- def transforms(model = nil, group = nil)
2520
+ # @return [OpenStudio::Point3dVector] non-collinears (see logs if empty)
2521
+ def getNonCollinears(pts = nil, n = 0)
1256
2522
  mth = "OSut::#{__callee__}"
1257
- cl1 = OpenStudio::Model::Model
1258
- cl2 = OpenStudio::Model::PlanarSurfaceGroup
1259
- res = { t: nil, r: nil }
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
1260
2543
 
1261
- return mismatch("model", model, cl1, mth, DBG, res) unless model.is_a?(cl1)
1262
- return invalid("group", mth, 2, DBG, res) unless group.respond_to?(NS)
2544
+ if holds?(a, pts[0])
2545
+ a = a.rotate(-1) unless same?(a[0], pts[0])
2546
+ end
1263
2547
 
1264
- id = group.nameString
1265
- return mismatch(id, group, cl2, mth, DBG, res) unless group.is_a?(cl2)
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
1266
2552
 
1267
- res[:t] = group.siteTransformation
1268
- res[:r] = group.directionofRelativeNorth + model.getBuilding.northAxis
2553
+ to_p3Dv(a)
2554
+ end
1269
2555
 
1270
- res
2556
+ ##
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.
2562
+ #
2563
+ # @param pts [Set<OpenStudio::Point3d>] 3D points
2564
+ # @param co [Bool] whether to keep collinear points
2565
+ #
2566
+ # @return [OpenStudio::Point3dVectorVector] line segments (see logs if empty)
2567
+ def getSegments(pts = nil, co = false)
2568
+ mth = "OSut::#{__callee__}"
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
2585
+ end
2586
+
2587
+ vv
1271
2588
  end
1272
2589
 
1273
2590
  ##
1274
- # Return a scalar product of an OpenStudio Vector3d.
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.
1275
2596
  #
1276
- # @param v [OpenStudio::Vector3d] a vector
1277
- # @param m [Float] a scalar
2597
+ # @param pts [OpenStudio::Point3dVector] 3D points
2598
+ # @param co [Bool] whether to keep collinear points
1278
2599
  #
1279
- # @return [OpenStudio::Vector3d] modified vector
1280
- # @return [OpenStudio::Vector3d] provided (or empty) vector if invalid input
1281
- def scalar(v = OpenStudio::Vector3d.new(0,0,0), m = 0)
2600
+ # @return [OpenStudio::Point3dVectorVector] triads (see logs if empty)
2601
+ def getTriads(pts = nil, co = false)
1282
2602
  mth = "OSut::#{__callee__}"
1283
- cl1 = OpenStudio::Vector3d
1284
- cl2 = Numeric
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
1285
2623
 
1286
- return mismatch("vector", v, cl1, mth, DBG, v) unless v.is_a?(cl1)
1287
- return mismatch("x", v.x, cl2, mth, DBG, v) unless v.x.respond_to?(:to_f)
1288
- return mismatch("y", v.y, cl2, mth, DBG, v) unless v.y.respond_to?(:to_f)
1289
- return mismatch("z", v.z, cl2, mth, DBG, v) unless v.z.respond_to?(:to_f)
1290
- return mismatch("m", m, cl2, mth, DBG, v) unless m.respond_to?(:to_f)
2624
+ vv
2625
+ end
1291
2626
 
1292
- OpenStudio::Vector3d.new(m * v.x, m * v.y, m * v.z)
2627
+ ##
2628
+ # Determines if pre-'aligned' OpenStudio 3D points are listed clockwise.
2629
+ #
2630
+ # @param pts [OpenStudio::Point3dVector] 3D points
2631
+ #
2632
+ # @return [Bool] whether sequence is clockwise
2633
+ # @return [false] if invalid input (see logs)
2634
+ def clockwise?(pts = nil)
2635
+ mth = "OSut::#{__callee__}"
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)
2640
+
2641
+ OpenStudio.pointInPolygon(pts.first, pts, TOL)
1293
2642
  end
1294
2643
 
1295
2644
  ##
1296
- # Flatten OpenStudio 3D points vs Z-axis (Z=0).
2645
+ # Returns 'aligned' OpenStudio 3D points conforming to Openstudio's
2646
+ # counterclockwise UpperLeftCorner (ULC) convention.
1297
2647
  #
1298
- # @param pts [Array] an OpenStudio Point3D array/vector
2648
+ # @param pts [Set<OpenStudio::Point3d>] aligned 3D points
1299
2649
  #
1300
- # @return [Array] flattened OpenStudio 3D points
1301
- def flatZ(pts = nil)
2650
+ # @return [OpenStudio::Point3dVector] ULC points (see logs if empty)
2651
+ def ulc(pts = nil)
1302
2652
  mth = "OSut::#{__callee__}"
1303
- cl1 = OpenStudio::Point3dVector
1304
- cl2 = OpenStudio::Point3d
2653
+ pts = to_p3Dv(pts)
1305
2654
  v = OpenStudio::Point3dVector.new
2655
+ p0 = OpenStudio::Point3d.new(0,0,0)
2656
+ i0 = nil
1306
2657
 
1307
- valid = pts.is_a?(cl1) || pts.is_a?(Array)
1308
- 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)
1309
2660
 
1310
- pts.each { |pt| mismatch("pt", pt, cl2, mth, ERR, v) unless pt.is_a?(cl2) }
1311
- 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)
1312
2664
 
1313
- 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)
1314
2677
  end
1315
2678
 
1316
2679
  ##
1317
- # Validate whether 1st OpenStudio convex polygon fits in 2nd convex polygon.
1318
- #
1319
- # @param p1 [OpenStudio::Point3dVector] or Point3D array of polygon #1
1320
- # @param p2 [OpenStudio::Point3dVector] or Point3D array of polygon #2
1321
- # @param id1 [String] polygon #1 identifier (optional)
1322
- # @param id2 [String] polygon #2 identifier (optional)
1323
- #
1324
- # @return [Bool] true if 1st polygon fits entirely within the 2nd polygon
1325
- # @return [Bool] false if invalid input
1326
- 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)
1327
2696
  mth = "OSut::#{__callee__}"
1328
- cl1 = OpenStudio::Point3dVector
1329
- cl2 = OpenStudio::Point3d
1330
- 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)
2703
+
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
2710
+
2711
+ # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- #
2712
+ # Basic tests:
2713
+ p3 = getNonCollinears(pts, 3)
2714
+ return empty("polygon", mth, ERR, v) if p3.size < 3
2715
+
2716
+ pln = OpenStudio::Plane.new(p3)
2717
+
2718
+ pts.each do |pt|
2719
+ return empty("plane", mth, ERR, v) unless pln.pointOnPlane(pt)
2720
+ end
2721
+
2722
+ t = tt
2723
+ t = OpenStudio::Transformation.alignFace(pts) unless tt.is_a?(cl)
2724
+ a = (t.inverse * pts).reverse
1331
2725
 
1332
- return invalid("id1", mth, 3, DBG, a) unless id1.respond_to?(:to_s)
1333
- return invalid("id2", mth, 4, DBG, a) unless id2.respond_to?(:to_s)
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
2736
+
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
2742
+
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?
2750
+
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
1334
2784
 
1335
- i1 = id1.to_s
1336
- i2 = id2.to_s
1337
- i1 = "poly1" if i1.empty?
1338
- i2 = "poly2" if i2.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
1339
2808
 
1340
- valid1 = p1.is_a?(cl1) || p1.is_a?(Array)
1341
- valid2 = p2.is_a?(cl1) || p2.is_a?(Array)
2809
+ a
2810
+ end
1342
2811
 
1343
- return mismatch(i1, p1, cl1, mth, DBG, a) unless valid1
1344
- return mismatch(i2, p2, cl1, mth, DBG, a) unless valid2
1345
- return empty(i1, mth, ERR, a) if p1.empty?
1346
- return empty(i2, mth, ERR, a) if p2.empty?
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__}"
1347
2821
 
1348
- p1.each { |v| return mismatch(i1, v, cl2, mth, ERR, a) unless v.is_a?(cl2) }
1349
- p2.each { |v| return mismatch(i2, v, cl2, mth, ERR, a) unless v.is_a?(cl2) }
2822
+ poly(pts, false, true, false, true).max_by(&:x).x
2823
+ end
1350
2824
 
1351
- # XY-plane transformation matrix ... needs to be clockwise for boost.
1352
- ft = OpenStudio::Transformation.alignFace(p1)
1353
- ft_p1 = flatZ( (ft.inverse * p1) )
1354
- return false if ft_p1.empty?
2825
+ ##
2826
+ # Returns 'height' of a set of OpenStudio 3D points (perpendicular view).
2827
+ #
2828
+ # @param pts [Set<OpenStudio::Point3d>] 3D points
2829
+ #
2830
+ # @return [Float] top-to-bottom height
2831
+ # @return [0.0] if invalid inputs (see logs)
2832
+ def height(pts = nil)
2833
+ mth = "OSut::#{__callee__}"
1355
2834
 
1356
- cw = OpenStudio.pointInPolygon(ft_p1.first, ft_p1, TOL)
1357
- ft_p1 = flatZ( (ft.inverse * p1).reverse ) unless cw
1358
- ft_p2 = flatZ( (ft.inverse * p2).reverse ) unless cw
1359
- ft_p2 = flatZ( (ft.inverse * p2) ) if cw
1360
- return false if ft_p2.empty?
2835
+ poly(pts, false, true, false, true).max_by(&:y).y
2836
+ end
1361
2837
 
1362
- area1 = OpenStudio.getArea(ft_p1)
1363
- area2 = OpenStudio.getArea(ft_p2)
1364
- return empty("#{i1} area", mth, ERR, a) if area1.empty?
1365
- return empty("#{i2} area", mth, ERR, a) if area2.empty?
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?
1366
2868
 
1367
2869
  area1 = area1.get
1368
2870
  area2 = area2.get
1369
- union = OpenStudio.join(ft_p1, ft_p2, TOL2)
1370
- return false if union.empty?
2871
+ union = OpenStudio.join(p1, p2, TOL2)
2872
+ return false if union.empty?
1371
2873
 
1372
2874
  union = union.get
1373
2875
  area = OpenStudio.getArea(union)
1374
- return empty("#{i1}:#{i2} union area", mth, ERR, a) if area.empty?
2876
+ return false if area.empty?
1375
2877
 
1376
2878
  area = area.get
1377
2879
 
1378
- return false if area < TOL
1379
- return true if (area - area2).abs < TOL
1380
- return false if (area - area2).abs > TOL
2880
+ if area > TOL
2881
+ return true if (area - area2).abs < TOL
2882
+ end
1381
2883
 
1382
- true
2884
+ false
1383
2885
  end
1384
2886
 
1385
2887
  ##
1386
- # Validate whether an OpenStudio polygon overlaps another.
2888
+ # Determines whether OpenStudio polygons overlap.
1387
2889
  #
1388
- # @param p1 [OpenStudio::Point3dVector] or Point3D array of polygon #1
1389
- # @param p2 [OpenStudio::Point3dVector] or Point3D array of polygon #2
1390
- # @param id1 [String] polygon #1 identifier (optional)
1391
- # @param id2 [String] polygon #2 identifier (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)
1392
2893
  #
1393
- # @return Returns true if polygons overlaps (or either fits into the other)
1394
- # @return [Bool] false if invalid input
1395
- def overlaps?(p1 = nil, p2 = nil, id1 = "", id2 = "")
1396
- mth = "OSut::#{__callee__}"
1397
- cl1 = OpenStudio::Point3dVector
1398
- cl2 = OpenStudio::Point3d
1399
- a = false
1400
-
1401
- return invalid("id1", mth, 3, DBG, a) unless id1.respond_to?(:to_s)
1402
- return invalid("id2", mth, 4, DBG, a) unless id2.respond_to?(:to_s)
1403
-
1404
- i1 = id1.to_s
1405
- i2 = id2.to_s
1406
- i1 = "poly1" if i1.empty?
1407
- i2 = "poly2" if i2.empty?
1408
-
1409
- valid1 = p1.is_a?(cl1) || p1.is_a?(Array)
1410
- valid2 = p2.is_a?(cl1) || p2.is_a?(Array)
1411
-
1412
- return mismatch(i1, p1, cl1, mth, DBG, a) unless valid1
1413
- return mismatch(i2, p2, cl1, mth, DBG, a) unless valid2
1414
- return empty(i1, mth, ERR, a) if p1.empty?
1415
- return empty(i2, mth, ERR, a) if p2.empty?
1416
-
1417
- p1.each { |v| return mismatch(i1, v, cl2, mth, ERR, a) unless v.is_a?(cl2) }
1418
- p2.each { |v| return mismatch(i2, v, cl2, mth, ERR, a) unless v.is_a?(cl2) }
1419
-
1420
- # XY-plane transformation matrix ... needs to be clockwise for boost.
1421
- ft = OpenStudio::Transformation.alignFace(p1)
1422
- ft_p1 = flatZ( (ft.inverse * p1) )
1423
- ft_p2 = flatZ( (ft.inverse * p2) )
1424
- return false if ft_p1.empty?
1425
- return false if ft_p2.empty?
1426
-
1427
- cw = OpenStudio.pointInPolygon(ft_p1.first, ft_p1, TOL)
1428
- ft_p1 = flatZ( (ft.inverse * p1).reverse ) unless cw
1429
- ft_p2 = flatZ( (ft.inverse * p2).reverse ) unless cw
1430
- return false if ft_p1.empty?
1431
- return false if ft_p2.empty?
1432
-
1433
- area1 = OpenStudio.getArea(ft_p1)
1434
- area2 = OpenStudio.getArea(ft_p2)
1435
- return empty("#{i1} area", mth, ERR, a) if area1.empty?
1436
- return empty("#{i2} area", mth, ERR, a) if area2.empty?
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?
1437
2920
 
1438
2921
  area1 = area1.get
1439
2922
  area2 = area2.get
1440
- union = OpenStudio.join(ft_p1, ft_p2, TOL2)
1441
- return false if union.empty?
2923
+ union = OpenStudio.join(p1, p2, TOL2)
2924
+ return false if union.empty?
1442
2925
 
1443
2926
  union = union.get
1444
2927
  area = OpenStudio.getArea(union)
1445
- return empty("#{i1}:#{i2} union area", mth, ERR, a) if area.empty?
2928
+ return false if area.empty?
1446
2929
 
1447
- area = area.get
1448
- return false if area < TOL
2930
+ area = area.get
2931
+ delta = area1 + area2 - area
1449
2932
 
1450
- true
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
2939
+
2940
+ false
1451
2941
  end
1452
2942
 
1453
2943
  ##
1454
- # Generate offset vertices (by width) for a 3- or 4-sided, convex polygon.
2944
+ # Generates offset vertices (by width) for a 3- or 4-sided, convex polygon.
1455
2945
  #
1456
- # @param p1 [OpenStudio::Point3dVector] OpenStudio Point3D vector/array
1457
- # @param w [Float] offset width (min: 0.0254m)
1458
- # @param v [Integer] OpenStudio SDK version, eg '321' for 'v3.2.1' (optional)
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)
1459
2949
  #
1460
- # @return [OpenStudio::Point3dVector] offset points if successful
1461
- # @return [OpenStudio::Point3dVector] original points if invalid input
1462
- def offset(p1 = [], w = 0, v = 0)
1463
- mth = "TBD::#{__callee__}"
1464
- cl = OpenStudio::Point3d
1465
- vrsn = OpenStudio.openStudioVersion.split(".").map(&:to_i).join.to_i
1466
-
1467
- valid = p1.is_a?(OpenStudio::Point3dVector) || p1.is_a?(Array)
1468
- return mismatch("pts", p1, cl1, mth, DBG, p1) unless valid
1469
- return empty("pts", mth, ERR, p1) if p1.empty?
1470
-
1471
- valid = p1.size == 3 || p1.size == 4
1472
- iv = true if p1.size == 4
1473
- return invalid("pts", mth, 1, DBG, p1) unless valid
1474
- return invalid("width", mth, 2, DBG, p1) unless w.respond_to?(:to_f)
1475
-
1476
- w = w.to_f
1477
- return p1 if w < 0.0254
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)
1478
2955
 
1479
- v = v.to_i if v.respond_to?(:to_i)
1480
- v = 0 unless v.respond_to?(:to_i)
1481
- v = vrsn if v.zero?
2956
+ mismatch("width", w, Numeric, mth) unless w.respond_to?(:to_f)
2957
+ mismatch("version", v, Integer, mth) unless v.respond_to?(:to_i)
1482
2958
 
1483
- p1.each { |x| return mismatch("p", x, cl, mth, ERR, p1) unless x.is_a?(cl) }
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
1484
2967
 
1485
2968
  unless v < 340
1486
- # XY-plane transformation matrix ... needs to be clockwise for boost.
1487
- ft = OpenStudio::Transformation::alignFace(p1)
1488
- ft_pts = flatZ( (ft.inverse * p1) )
1489
- return p1 if ft_pts.empty?
1490
-
1491
- cw = OpenStudio::pointInPolygon(ft_pts.first, ft_pts, TOL)
1492
- ft_pts = flatZ( (ft.inverse * p1).reverse ) unless cw
1493
- offset = OpenStudio.buffer(ft_pts, w, TOL)
1494
- return p1 if offset.empty?
1495
-
1496
- offset = offset.get
1497
- offset = ft * offset if cw
1498
- offset = (ft * offset).reverse unless cw
1499
-
1500
- pz = OpenStudio::Point3dVector.new
1501
- offset.each { |o| pz << OpenStudio::Point3d.new(o.x, o.y, o.z ) }
1502
-
1503
- return pz
1504
- else # brute force approach
2969
+ t = OpenStudio::Transformation.alignFace(p1)
2970
+ offset = OpenStudio.buffer(pts, w, TOL)
2971
+ return p1 if offset.empty?
2972
+
2973
+ return to_p3Dv(t * offset.get.reverse)
2974
+ else # brute force approach
1505
2975
  pz = {}
1506
2976
  pz[:A] = {}
1507
2977
  pz[:B] = {}
@@ -1684,6 +3154,851 @@ module OSut
1684
3154
  end
1685
3155
  end
1686
3156
 
3157
+ ##
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.
3391
+ #
3392
+ # @param space [OpenStudio::Model::Space] a space
3393
+ #
3394
+ # @return [Array<OpenStudio::Model::Surface>] surfaces (see logs if empty)
3395
+ def getRoofs(space = nil)
3396
+ mth = "OSut::#{__callee__}"
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"}
3405
+
3406
+ clngs = clngs.select {|s| s.surfaceType.downcase == "roofceiling"}
3407
+ clngs = clngs.select {|s| s.outsideBoundaryCondition.downcase == "surface"}
3408
+
3409
+ clngs.each do |ceiling|
3410
+ floor = ceiling.adjacentSurface
3411
+ next if floor.empty?
3412
+
3413
+ other = floor.get.space
3414
+ next if other.empty?
3415
+
3416
+ rufs = other.get.surfaces
3417
+
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
3428
+ end
3429
+
3430
+ roofs
3431
+ end
3432
+
3433
+ ##
3434
+ # Adds sub surfaces (e.g. windows, doors, skylights) to surface.
3435
+ #
3436
+ # @param s [OpenStudio::Model::Surface] a model surface
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)
3459
+ mth = "OSut::#{__callee__}"
3460
+ v = OpenStudio.openStudioVersion.split(".").join.to_i
3461
+ cl1 = OpenStudio::Model::Surface
3462
+ cl2 = Array
3463
+ cl3 = Hash
3464
+ min = 0.050 # minimum ratio value ( 5%)
3465
+ max = 0.950 # maximum ratio value (95%)
3466
+ no = false
3467
+
3468
+ # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- #
3469
+ # Exit if mismatched or invalid argument classes.
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?
3473
+
3474
+ # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- #
3475
+ # Clear existing sub surfaces if requested.
3476
+ nom = s.nameString
3477
+ mdl = s.model
3478
+
3479
+ unless [true, false].include?(clear)
3480
+ log(WRN, "#{nom}: Keeping existing sub surfaces (#{mth})")
3481
+ clear = false
3482
+ end
3483
+
3484
+ s.subSurfaces.map(&:remove) if clear
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
+
3499
+ # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- #
3500
+ # Allowable sub surface types ... & Frame&Divider enabled
3501
+ # - "FixedWindow" | true
3502
+ # - "OperableWindow" | true
3503
+ # - "Door" | false
3504
+ # - "GlassDoor" | true
3505
+ # - "OverheadDoor" | false
3506
+ # - "Skylight" | false if v < 321
3507
+ # - "TubularDaylightDome" | false
3508
+ # - "TubularDaylightDiffuser" | false
3509
+ type = "FixedWindow"
3510
+ types = OpenStudio::Model::SubSurface.validSubSurfaceTypeValues
3511
+ stype = s.surfaceType # Wall, RoofCeiling or Floor
3512
+
3513
+ # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- #
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
3519
+
3520
+ # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- #
3521
+ # Assign default values to certain sub keys (if missing), +more validation.
3522
+ subs.each_with_index do |sub, index|
3523
+ return mismatch("sub", sub, cl4, mth, DBG, no) unless sub.is_a?(cl3)
3524
+
3525
+ # Required key:value pairs (either set by the user or defaulted).
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]
3544
+
3545
+ # If sub surface type is invalid, log/reset. Additional corrections may
3546
+ # be enabled once a sub surface is actually instantiated.
3547
+ unless types.include?(sub[:type])
3548
+ log(WRN, "Reset invalid '#{id}' type to '#{type}' (#{mth})")
3549
+ sub[:type] = type
3550
+ end
3551
+
3552
+ # Log/ignore (optional) frame & divider object.
3553
+ unless sub[:frame].nil?
3554
+ if sub[:frame].respond_to?(:frameWidth)
3555
+ sub[:frame] = nil if sub[:type] == "Skylight" && v < 321
3556
+ sub[:frame] = nil if sub[:type] == "Door"
3557
+ sub[:frame] = nil if sub[:type] == "OverheadDoor"
3558
+ sub[:frame] = nil if sub[:type] == "TubularDaylightDome"
3559
+ sub[:frame] = nil if sub[:type] == "TubularDaylightDiffuser"
3560
+ log(WRN, "Skip '#{id}' FrameDivider (#{mth})") if sub[:frame].nil?
3561
+ else
3562
+ sub[:frame] = nil
3563
+ log(WRN, "Skip '#{id}' invalid FrameDivider object (#{mth})")
3564
+ end
3565
+ end
3566
+
3567
+ # The (optional) "assembly" must reference a valid OpenStudio
3568
+ # construction base, to explicitly assign to each instantiated sub
3569
+ # surface. If invalid, log/reset/ignore. Additional checks are later
3570
+ # activated once a sub surface is actually instantiated.
3571
+ unless sub[:assembly].nil?
3572
+ unless sub[:assembly].respond_to?(:isFenestration)
3573
+ log(WRN, "Skip invalid '#{id}' construction (#{mth})")
3574
+ sub[:assembly] = nil
3575
+ end
3576
+ end
3577
+
3578
+ # Log/reset negative float values. Set ~0.0 values to 0.0.
3579
+ sub.each do |key, value|
3580
+ next if key == :count
3581
+ next if key == :multiplier
3582
+ next if key == :type
3583
+ next if key == :id
3584
+ next if key == :frame
3585
+ next if key == :assembly
3586
+
3587
+ ok = value.respond_to?(:to_f)
3588
+ return mismatch(key, value, Float, mth, DBG, no) unless ok
3589
+ next if key == :centreline
3590
+
3591
+ negative(key, mth, WRN) if value < 0
3592
+ value = 0.0 if value.abs < TOL
3593
+ end
3594
+ end
3595
+
3596
+ # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- #
3597
+ # Log/reset (or abandon) conflicting user-set geometry key:value pairs:
3598
+ # :head e.g. std 80" door + frame/buffers (+ m)
3599
+ # :sill e.g. std 30" sill + frame/buffers (+ m)
3600
+ # :height any sub surface height, below "head" (+ m)
3601
+ # :width e.g. 1.200 m
3602
+ # :offset if array (+ m)
3603
+ # :centreline left or right of base surface centreline (+/- m)
3604
+ # :r_buffer buffer between sub/array and right-side corner (+ m)
3605
+ # :l_buffer buffer between sub/array and left-side corner (+ m)
3606
+ #
3607
+ # If successful, this will generate sub surfaces and add them to the model.
3608
+ subs.each do |sub|
3609
+ # Set-up unique sub parameters:
3610
+ # - Frame & Divider "width"
3611
+ # - minimum "clear glazing" limits
3612
+ # - buffers, etc.
3613
+ id = sub[:id]
3614
+ frame = 0
3615
+ frame = sub[:frame].frameWidth unless sub[:frame].nil?
3616
+ frames = 2 * frame
3617
+ buffer = frame + bfr
3618
+ buffers = 2 * buffer
3619
+ dim = 0.200 unless (3 * frame) > 0.200
3620
+ dim = 3 * frame if (3 * frame) > 0.200
3621
+ glass = dim - frames
3622
+ min_sill = buffer
3623
+ min_head = buffers + glass
3624
+ max_head = max_y - buffer
3625
+ max_sill = max_head - (buffers + glass)
3626
+ min_ljamb = buffer
3627
+ max_ljamb = max_x - (buffers + glass)
3628
+ min_rjamb = buffers + glass
3629
+ max_rjamb = max_x - buffer
3630
+ max_height = max_y - buffers
3631
+ max_width = max_x - buffers
3632
+
3633
+ # Default sub surface "head" & "sill" height, unless user-specified.
3634
+ typ_head = HEAD
3635
+ typ_sill = SILL
3636
+
3637
+ if sub.key?(:ratio)
3638
+ typ_head = mid_y * (1 + sub[:ratio]) if sub[:ratio] > 0.75
3639
+ typ_head = mid_y * (1 + sub[:ratio]) unless stype.downcase == "wall"
3640
+ typ_sill = mid_y * (1 - sub[:ratio]) if sub[:ratio] > 0.75
3641
+ typ_sill = mid_y * (1 - sub[:ratio]) unless stype.downcase == "wall"
3642
+ end
3643
+
3644
+ # Log/reset "height" if beyond min/max.
3645
+ if sub.key?(:height)
3646
+ unless sub[:height].between?(glass, max_height)
3647
+ sub[:height] = glass if sub[:height] < glass
3648
+ sub[:height] = max_height if sub[:height] > max_height
3649
+ log(WRN, "Reset '#{id}' height to #{sub[:height]} m (#{mth})")
3650
+ end
3651
+ end
3652
+
3653
+ # Log/reset "head" height if beyond min/max.
3654
+ if sub.key?(:head)
3655
+ unless sub[:head].between?(min_head, max_head)
3656
+ sub[:head] = max_head if sub[:head] > max_head
3657
+ sub[:head] = min_head if sub[:head] < min_head
3658
+ log(WRN, "Reset '#{id}' head height to #{sub[:head]} m (#{mth})")
3659
+ end
3660
+ end
3661
+
3662
+ # Log/reset "sill" height if beyond min/max.
3663
+ if sub.key?(:sill)
3664
+ unless sub[:sill].between?(min_sill, max_sill)
3665
+ sub[:sill] = max_sill if sub[:sill] > max_sill
3666
+ sub[:sill] = min_sill if sub[:sill] < min_sill
3667
+ log(WRN, "Reset '#{id}' sill height to #{sub[:sill]} m (#{mth})")
3668
+ end
3669
+ end
3670
+
3671
+ # At this point, "head", "sill" and/or "height" have been tentatively
3672
+ # validated (and/or have been corrected) independently from one another.
3673
+ # Log/reset "head" & "sill" heights if conflicting.
3674
+ if sub.key?(:head) && sub.key?(:sill) && sub[:head] < sub[:sill] + glass
3675
+ sill = sub[:head] - glass
3676
+
3677
+ if sill < min_sill
3678
+ sub[:ratio ] = 0 if sub.key?(:ratio)
3679
+ sub[:count ] = 0
3680
+ sub[:multiplier] = 0
3681
+ sub[:height ] = 0 if sub.key?(:height)
3682
+ sub[:width ] = 0 if sub.key?(:width)
3683
+ log(ERR, "Skip: invalid '#{id}' head/sill combo (#{mth})")
3684
+ next
3685
+ else
3686
+ sub[:sill] = sill
3687
+ log(WRN, "(Re)set '#{id}' sill height to #{sub[:sill]} m (#{mth})")
3688
+ end
3689
+ end
3690
+
3691
+ # Attempt to reconcile "head", "sill" and/or "height". If successful,
3692
+ # all 3x parameters are set (if missing), or reset if invalid.
3693
+ if sub.key?(:head) && sub.key?(:sill)
3694
+ height = sub[:head] - sub[:sill]
3695
+
3696
+ if sub.key?(:height) && (sub[:height] - height).abs > TOL
3697
+ log(WRN, "(Re)set '#{id}' height to #{height} m (#{mth})")
3698
+ end
3699
+
3700
+ sub[:height] = height
3701
+ elsif sub.key?(:head) # no "sill"
3702
+ if sub.key?(:height)
3703
+ sill = sub[:head] - sub[:height]
3704
+
3705
+ if sill < min_sill
3706
+ sill = min_sill
3707
+ height = sub[:head] - sill
3708
+
3709
+ if height < glass
3710
+ sub[:ratio ] = 0 if sub.key?(:ratio)
3711
+ sub[:count ] = 0
3712
+ sub[:multiplier] = 0
3713
+ sub[:height ] = 0 if sub.key?(:height)
3714
+ sub[:width ] = 0 if sub.key?(:width)
3715
+ log(ERR, "Skip: invalid '#{id}' head/height combo (#{mth})")
3716
+ next
3717
+ else
3718
+ sub[:sill ] = sill
3719
+ sub[:height] = height
3720
+ log(WRN, "(Re)set '#{id}' height to #{sub[:height]} m (#{mth})")
3721
+ end
3722
+ else
3723
+ sub[:sill] = sill
3724
+ end
3725
+ else
3726
+ sub[:sill ] = typ_sill
3727
+ sub[:height] = sub[:head] - sub[:sill]
3728
+ end
3729
+ elsif sub.key?(:sill) # no "head"
3730
+ if sub.key?(:height)
3731
+ head = sub[:sill] + sub[:height]
3732
+
3733
+ if head > max_head
3734
+ head = max_head
3735
+ height = head - sub[:sill]
3736
+
3737
+ if height < glass
3738
+ sub[:ratio ] = 0 if sub.key?(:ratio)
3739
+ sub[:count ] = 0
3740
+ sub[:multiplier] = 0
3741
+ sub[:height ] = 0 if sub.key?(:height)
3742
+ sub[:width ] = 0 if sub.key?(:width)
3743
+ log(ERR, "Skip: invalid '#{id}' sill/height combo (#{mth})")
3744
+ next
3745
+ else
3746
+ sub[:head ] = head
3747
+ sub[:height] = height
3748
+ log(WRN, "(Re)set '#{id}' height to #{sub[:height]} m (#{mth})")
3749
+ end
3750
+ else
3751
+ sub[:head] = head
3752
+ end
3753
+ else
3754
+ sub[:head ] = typ_head
3755
+ sub[:height] = sub[:head] - sub[:sill]
3756
+ end
3757
+ elsif sub.key?(:height) # neither "head" nor "sill"
3758
+ head = typ_head
3759
+ sill = head - sub[:height]
3760
+
3761
+ if sill < min_sill
3762
+ sill = min_sill
3763
+ head = sill + sub[:height]
3764
+ end
3765
+
3766
+ sub[:head] = head
3767
+ sub[:sill] = sill
3768
+ else
3769
+ sub[:head ] = typ_head
3770
+ sub[:sill ] = typ_sill
3771
+ sub[:height] = sub[:head] - sub[:sill]
3772
+ end
3773
+
3774
+ # Log/reset "width" if beyond min/max.
3775
+ if sub.key?(:width)
3776
+ unless sub[:width].between?(glass, max_width)
3777
+ sub[:width] = glass if sub[:width] < glass
3778
+ sub[:width] = max_width if sub[:width] > max_width
3779
+ log(WRN, "Reset '#{id}' width to #{sub[:width]} m (#{mth})")
3780
+ end
3781
+ end
3782
+
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
+
3787
+ if sub[:count] < 1
3788
+ sub[:count] = 1
3789
+ log(WRN, "Reset '#{id}' count to #{sub[:count]} (#{mth})")
3790
+ end
3791
+ else
3792
+ sub[:count] = 1
3793
+ end
3794
+
3795
+ sub[:count] = 1 unless sub.key?(:count)
3796
+
3797
+ # Log/reset if left-sided buffer under min jamb position.
3798
+ if sub.key?(:l_buffer)
3799
+ if sub[:l_buffer] < min_ljamb
3800
+ sub[:l_buffer] = min_ljamb
3801
+ log(WRN, "Reset '#{id}' left buffer to #{sub[:l_buffer]} m (#{mth})")
3802
+ end
3803
+ end
3804
+
3805
+ # Log/reset if right-sided buffer beyond max jamb position.
3806
+ if sub.key?(:r_buffer)
3807
+ if sub[:r_buffer] > max_rjamb
3808
+ sub[:r_buffer] = min_rjamb
3809
+ log(WRN, "Reset '#{id}' right buffer to #{sub[:r_buffer]} m (#{mth})")
3810
+ end
3811
+ end
3812
+
3813
+ centre = mid_x
3814
+ centre += sub[:centreline] if sub.key?(:centreline)
3815
+ n = sub[:count ]
3816
+ h = sub[:height ] + frames
3817
+ w = 0 # overall width of sub(s) bounding box (to calculate)
3818
+ x0 = 0 # left-side X-axis coordinate of sub(s) bounding box
3819
+ xf = 0 # right-side X-axis coordinate of sub(s) bounding box
3820
+
3821
+ # Log/reset "offset", if conflicting vs "width".
3822
+ if sub.key?(:ratio)
3823
+ if sub[:ratio] < TOL
3824
+ sub[:ratio ] = 0
3825
+ sub[:count ] = 0
3826
+ sub[:multiplier] = 0
3827
+ sub[:height ] = 0 if sub.key?(:height)
3828
+ sub[:width ] = 0 if sub.key?(:width)
3829
+ log(ERR, "Skip: '#{id}' ratio ~0 (#{mth})")
3830
+ next
3831
+ end
3832
+
3833
+ # Log/reset if "ratio" beyond min/max?
3834
+ unless sub[:ratio].between?(min, max)
3835
+ sub[:ratio] = min if sub[:ratio] < min
3836
+ sub[:ratio] = max if sub[:ratio] > max
3837
+ log(WRN, "Reset ratio (min/max) to #{sub[:ratio]} (#{mth})")
3838
+ end
3839
+
3840
+ # Log/reset "count" unless 1.
3841
+ unless sub[:count] == 1
3842
+ sub[:count] = 1
3843
+ log(WRN, "Reset count (ratio) to 1 (#{mth})")
3844
+ end
3845
+
3846
+ area = s.grossArea * sub[:ratio] # sub m2, including (optional) frames
3847
+ w = area / h
3848
+ width = w - frames
3849
+ x0 = centre - w/2
3850
+ xf = centre + w/2
3851
+
3852
+ if sub.key?(:l_buffer)
3853
+ if sub.key?(:centreline)
3854
+ log(WRN, "Skip #{id} left buffer (vs centreline) (#{mth})")
3855
+ else
3856
+ x0 = sub[:l_buffer] - frame
3857
+ xf = x0 + w
3858
+ centre = x0 + w/2
3859
+ end
3860
+ elsif sub.key?(:r_buffer)
3861
+ if sub.key?(:centreline)
3862
+ log(WRN, "Skip #{id} right buffer (vs centreline) (#{mth})")
3863
+ else
3864
+ xf = max_x - sub[:r_buffer] + frame
3865
+ x0 = xf - w
3866
+ centre = x0 + w/2
3867
+ end
3868
+ end
3869
+
3870
+ # Too wide?
3871
+ if x0 < min_ljamb || xf > max_rjamb
3872
+ sub[:ratio ] = 0 if sub.key?(:ratio)
3873
+ sub[:count ] = 0
3874
+ sub[:multiplier] = 0
3875
+ sub[:height ] = 0 if sub.key?(:height)
3876
+ sub[:width ] = 0 if sub.key?(:width)
3877
+ log(ERR, "Skip: invalid (ratio) width/centreline (#{mth})")
3878
+ next
3879
+ end
3880
+
3881
+ if sub.key?(:width) && (sub[:width] - width).abs > TOL
3882
+ sub[:width] = width
3883
+ log(WRN, "Reset width (ratio) to #{sub[:width]} (#{mth})")
3884
+ end
3885
+
3886
+ sub[:width] = width unless sub.key?(:width)
3887
+ else
3888
+ unless sub.key?(:width)
3889
+ sub[:ratio ] = 0 if sub.key?(:ratio)
3890
+ sub[:count ] = 0
3891
+ sub[:multiplier] = 0
3892
+ sub[:height ] = 0 if sub.key?(:height)
3893
+ sub[:width ] = 0 if sub.key?(:width)
3894
+ log(ERR, "Skip: missing '#{id}' width (#{mth})")
3895
+ next
3896
+ end
3897
+
3898
+ width = sub[:width] + frames
3899
+ gap = (max_x - n * width) / (n + 1)
3900
+ gap = sub[:offset] - width if sub.key?(:offset)
3901
+ gap = 0 if gap < bfr
3902
+ offset = gap + width
3903
+
3904
+ if sub.key?(:offset) && (offset - sub[:offset]).abs > TOL
3905
+ sub[:offset] = offset
3906
+ log(WRN, "Reset sub offset to #{sub[:offset]} m (#{mth})")
3907
+ end
3908
+
3909
+ sub[:offset] = offset unless sub.key?(:offset)
3910
+
3911
+ # Overall width (including frames) of bounding box around array.
3912
+ w = n * width + (n - 1) * gap
3913
+ x0 = centre - w/2
3914
+ xf = centre + w/2
3915
+
3916
+ if sub.key?(:l_buffer)
3917
+ if sub.key?(:centreline)
3918
+ log(WRN, "Skip #{id} left buffer (vs centreline) (#{mth})")
3919
+ else
3920
+ x0 = sub[:l_buffer] - frame
3921
+ xf = x0 + w
3922
+ centre = x0 + w/2
3923
+ end
3924
+ elsif sub.key?(:r_buffer)
3925
+ if sub.key?(:centreline)
3926
+ log(WRN, "Skip #{id} right buffer (vs centreline) (#{mth})")
3927
+ else
3928
+ xf = max_x - sub[:r_buffer] + frame
3929
+ x0 = xf - w
3930
+ centre = x0 + w/2
3931
+ end
3932
+ end
3933
+
3934
+ # Too wide?
3935
+ if x0 < bfr || xf > max_x - bfr
3936
+ sub[:ratio ] = 0 if sub.key?(:ratio)
3937
+ sub[:count ] = 0
3938
+ sub[:multiplier] = 0
3939
+ sub[:height ] = 0 if sub.key?(:height)
3940
+ sub[:width ] = 0 if sub.key?(:width)
3941
+ log(ERR, "Skip: invalid array width/centreline (#{mth})")
3942
+ next
3943
+ end
3944
+ end
3945
+
3946
+ # Initialize left-side X-axis coordinate of only/first sub.
3947
+ pos = x0 + frame
3948
+
3949
+ # Generate sub(s).
3950
+ sub[:count].times do |i|
3951
+ name = "#{id}|#{i}"
3952
+ fr = 0
3953
+ fr = sub[:frame].frameWidth if sub[:frame]
3954
+
3955
+ vec = OpenStudio::Point3dVector.new
3956
+ vec << OpenStudio::Point3d.new(pos, sub[:head], 0)
3957
+ vec << OpenStudio::Point3d.new(pos, sub[:sill], 0)
3958
+ vec << OpenStudio::Point3d.new(pos + sub[:width], sub[:sill], 0)
3959
+ vec << OpenStudio::Point3d.new(pos + sub[:width], sub[:head], 0)
3960
+ vec = t * vec
3961
+
3962
+ # Log/skip if conflict between individual sub and base surface.
3963
+ vc = vec
3964
+ vc = offset(vc, fr, 300) if fr > 0
3965
+ ok = fits?(vc, s)
3966
+ log(ERR, "Skip '#{name}': won't fit in '#{nom}' (#{mth})") unless ok
3967
+ break unless ok
3968
+
3969
+ # Log/skip if conflicts with existing subs (even if same array).
3970
+ s.subSurfaces.each do |sb|
3971
+ nome = sb.nameString
3972
+ fd = sb.windowPropertyFrameAndDivider
3973
+ fr = 0 if fd.empty?
3974
+ fr = fd.get.frameWidth unless fd.empty?
3975
+ vk = sb.vertices
3976
+ vk = offset(vk, fr, 300) if fr > 0
3977
+ oops = overlaps?(vc, vk)
3978
+ log(ERR, "Skip '#{name}': overlaps '#{nome}' (#{mth})") if oops
3979
+ ok = false if oops
3980
+ break if oops
3981
+ end
3982
+
3983
+ break unless ok
3984
+
3985
+ sb = OpenStudio::Model::SubSurface.new(vec, mdl)
3986
+ sb.setName(name)
3987
+ sb.setSubSurfaceType(sub[:type])
3988
+ sb.setConstruction(sub[:assembly]) if sub[:assembly]
3989
+ ok = sb.allowWindowPropertyFrameAndDivider
3990
+ sb.setWindowPropertyFrameAndDivider(sub[:frame]) if sub[:frame] && ok
3991
+ sb.setMultiplier(sub[:multiplier]) if sub[:multiplier] > 1
3992
+ sb.setSurface(s)
3993
+
3994
+ # Reset "pos" if array.
3995
+ pos += sub[:offset] if sub.key?(:offset)
3996
+ end
3997
+ end
3998
+
3999
+ true
4000
+ end
4001
+
1687
4002
  ##
1688
4003
  # Callback when other modules extend OSlg
1689
4004
  #