osut 0.2.8 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
  #