osut 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
data/lib/osut/utils.rb ADDED
@@ -0,0 +1,1430 @@
1
+ # BSD 3-Clause License
2
+ #
3
+ # Copyright (c) 2022, Denis Bourgeois
4
+ # All rights reserved.
5
+ #
6
+ # Redistribution and use in source and binary forms, with or without
7
+ # modification, are permitted provided that the following conditions are met:
8
+ #
9
+ # 1. Redistributions of source code must retain the above copyright notice, this
10
+ # list of conditions and the following disclaimer.
11
+ #
12
+ # 2. Redistributions in binary form must reproduce the above copyright notice,
13
+ # this list of conditions and the following disclaimer in the documentation
14
+ # and/or other materials provided with the distribution.
15
+ #
16
+ # 3. Neither the name of the copyright holder nor the names of its
17
+ # contributors may be used to endorse or promote products derived from
18
+ # this software without specific prior written permission.
19
+ #
20
+ # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
21
+ # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
22
+ # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
23
+ # DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
24
+ # FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
25
+ # DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
26
+ # SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
27
+ # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
28
+ # OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29
+ # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
30
+
31
+ require "openstudio"
32
+
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
+ NS = "nameString" # OpenStudio IdfObject nameString method
39
+ DBG = OSut::DEBUG # mainly to flag invalid arguments to devs (buggy code)
40
+ INF = OSut::INFO # not currently used in OSut
41
+ WRN = OSut::WARN # WARN users of 'iffy' .osm inputs (yet not critical)
42
+ ERR = OSut::ERROR # flag invalid .osm inputs (then exit via 'return')
43
+ FTL = OSut::FATAL # not currently used in OSut
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
47
+ # 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.
49
+ # Both documents share many similarities, regardless of nomenclature. There
50
+ # are however noticeable differences between approaches on how a space is
51
+ # tagged as falling into one of the aforementioned categories. First, an
52
+ # overview of 90.1 requirements, with some minor edits for brevity/emphasis:
53
+ #
54
+ # www.pnnl.gov/main/publications/external/technical_reports/PNNL-26917.pdf
55
+ #
56
+ # 3.2.1. General Information - SPACE CONDITIONING CATEGORY
57
+ #
58
+ # - CONDITIONED space: an ENCLOSED space that has a heating and/or
59
+ # cooling system of sufficient size to maintain temperatures suitable
60
+ # for HUMAN COMFORT:
61
+ # - COOLED: cooled by a system >= 10 W/m2
62
+ # - HEATED: heated by a system e.g., >= 50 W/m2 in Climate Zone CZ-7
63
+ # - INDIRECTLY: heated or cooled via adjacent space(s) provided:
64
+ # - UA of adjacent surfaces > UA of other surfaces
65
+ # or
66
+ # - intentional air transfer from HEATED/COOLED space > 3 ACH
67
+ #
68
+ # ... includes plenums, atria, etc.
69
+ #
70
+ # - SEMI-HEATED space: an ENCLOSED space that has a heating system
71
+ # >= 10 W/m2, yet NOT a CONDITIONED space (see above).
72
+ #
73
+ # - UNCONDITIONED space: an ENCLOSED space that is NOT a conditioned
74
+ # space or a SEMI-HEATED space (see above).
75
+ #
76
+ # NOTE: Crawlspaces, attics, and parking garages with natural or
77
+ # mechanical ventilation are considered UNENCLOSED spaces.
78
+ #
79
+ # 2.3.3 Modeling Requirements: surfaces adjacent to UNENCLOSED spaces
80
+ # shall be treated as exterior surfaces. All other UNENCLOSED surfaces
81
+ # are to be modeled as is in both proposed and baseline models. For
82
+ # instance, modeled fenestration in UNENCLOSED spaces would not be
83
+ # factored in WWR calculations.
84
+ #
85
+ #
86
+ # Related NECB definitions and concepts, starting with CONDITIONED space:
87
+ #
88
+ # "[...] the temperature of which is controlled to limit variation in
89
+ # response to the exterior ambient temperature by the provision, either
90
+ # DIRECTLY or INDIRECTLY, of heating or cooling [...]". Although criteria
91
+ # differ (e.g., not sizing-based), the general idea is sufficiently similar
92
+ # to ASHRAE 90.1 (e.g., heating and/or cooling based, no distinction for
93
+ # INDIRECTLY conditioned spaces like plenums).
94
+ #
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.
99
+ #
100
+ # The single NECB criterion distinguishing UNCONDITIONED ENCLOSED spaces
101
+ # (such as vestibules) from UNENCLOSED spaces (such as attics) remains the
102
+ # 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.
111
+ #
112
+ # For an OpenStudio model (OSM) in an incomplete or preliminary state, e.g.
113
+ # holding fully-formed ENCLOSED spaces without thermal zoning information or
114
+ # setpoint temperatures (early design stage assessments of form, porosity or
115
+ # envelope), all OSM spaces will be considered CONDITIONED, presuming
116
+ # setpoints of ~21°C (heating) and ~24°C (cooling).
117
+ #
118
+ # If ANY valid space/zone-specific temperature setpoints are found in the OSM,
119
+ # spaces/zones WITHOUT valid heating or cooling setpoints are considered as
120
+ # UNCONDITIONED or UNENCLOSED spaces (like attics), or INDIRECTLY CONDITIONED
121
+ # spaces (like plenums), see "plenum?" method.
122
+
123
+ ##
124
+ # Return min & max values of a schedule (ruleset).
125
+ #
126
+ # @param sched [OpenStudio::Model::ScheduleRuleset] schedule
127
+ #
128
+ # @return [Hash] min: (Float), max: (Float)
129
+ # @return [Hash] min: nil, max: nil (if invalid input)
130
+ def scheduleRulesetMinMax(sched)
131
+ # Largely inspired from David Goldwasser's
132
+ # "schedule_ruleset_annual_min_max_value":
133
+ #
134
+ # github.com/NREL/openstudio-standards/blob/
135
+ # 99cf713750661fe7d2082739f251269c2dfd9140/lib/openstudio-standards/
136
+ # standards/Standards.ScheduleRuleset.rb#L124
137
+ mth = "OSut::#{__callee__}"
138
+ cl = OpenStudio::Model::ScheduleRuleset
139
+ res = { min: nil, max: nil }
140
+
141
+ return invalid("sched", mth, 1, DBG, res) unless sched.respond_to?(NS)
142
+ id = sched.nameString
143
+ return mismatch(id, sched, cl, mth, DBG, res) unless sched.is_a?(cl)
144
+
145
+ profiles = []
146
+ profiles << sched.defaultDaySchedule
147
+ sched.scheduleRules.each { |rule| profiles << rule.daySchedule }
148
+
149
+ profiles.each do |profile|
150
+ id = profile.nameString
151
+ profile.values.each do |val|
152
+ unless val.is_a?(Numeric)
153
+ log(WRN, "Skipping non-numeric profile values in '#{id}' (#{mth})")
154
+ next
155
+ end
156
+
157
+ res[:min] = val unless res[:min]
158
+ res[:min] = val if res[:min] > val
159
+ res[:max] = val unless res[:max]
160
+ res[:max] = val if res[:max] < val
161
+ end
162
+ end
163
+
164
+ valid = res[:min] && res[:max]
165
+ log(ERR, "Invalid MIN/MAX in '#{id}' (#{mth})") unless valid
166
+
167
+ res
168
+ end
169
+
170
+ ##
171
+ # Return min & max values of a schedule (constant).
172
+ #
173
+ # @param sched [OpenStudio::Model::ScheduleConstant] schedule
174
+ #
175
+ # @return [Hash] min: (Float), max: (Float)
176
+ # @return [Hash] min: nil, max: nil (if invalid input)
177
+ def scheduleConstantMinMax(sched)
178
+ # Largely inspired from David Goldwasser's
179
+ # "schedule_constant_annual_min_max_value":
180
+ #
181
+ # github.com/NREL/openstudio-standards/blob/
182
+ # 99cf713750661fe7d2082739f251269c2dfd9140/lib/openstudio-standards/
183
+ # standards/Standards.ScheduleConstant.rb#L21
184
+ mth = "OSut::#{__callee__}"
185
+ cl = OpenStudio::Model::ScheduleConstant
186
+ res = { min: nil, max: nil }
187
+
188
+ return invalid("sched", mth, 1, DBG, res) unless sched.respond_to?(NS)
189
+ id = sched.nameString
190
+ return mismatch(id, sched, cl, mth, DBG, res) unless sched.is_a?(cl)
191
+
192
+ unless sched.value.is_a?(Numeric)
193
+ return mismatch("'#{id}' value", sched.value, Numeric, mth, ERR, res)
194
+ else
195
+ res[:min] = sched.value
196
+ res[:max] = sched.value
197
+ end
198
+
199
+ res
200
+ end
201
+
202
+ ##
203
+ # Return min & max values of a schedule (compact).
204
+ #
205
+ # @param sched [OpenStudio::Model::ScheduleCompact] schedule
206
+ #
207
+ # @return [Hash] min: (Float), max: (Float)
208
+ # @return [Hash] min: nil, max: nil (if invalid input)
209
+ def scheduleCompactMinMax(sched)
210
+ # Largely inspired from Andrew Parker's
211
+ # "schedule_compact_annual_min_max_value":
212
+ #
213
+ # github.com/NREL/openstudio-standards/blob/
214
+ # 99cf713750661fe7d2082739f251269c2dfd9140/lib/openstudio-standards/
215
+ # 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)
223
+ id = sched.nameString
224
+ return mismatch(id, sched, cl, mth, DBG, res) unless sched.is_a?(cl)
225
+
226
+ sched.extensibleGroups.each do |eg|
227
+ if prev_str.include?("until")
228
+ vals << eg.getDouble(0).get unless eg.getDouble(0).empty?
229
+ end
230
+
231
+ str = eg.getString(0)
232
+ prev_str = str.get.downcase unless str.empty?
233
+ end
234
+
235
+ return empty("'#{id}' values", mth, ERR, res) if vals.empty?
236
+
237
+ if vals.min.is_a?(Numeric) && vals.max.is_a?(Numeric)
238
+ res[:min] = vals.min
239
+ res[:max] = vals.max
240
+ else
241
+ log(ERR, "Non-numeric values in '#{id}' (#{mth})")
242
+ end
243
+
244
+ res
245
+ end
246
+
247
+ ##
248
+ # Return min & max values for schedule (interval).
249
+ #
250
+ # @param sched [OpenStudio::Model::ScheduleInterval] schedule
251
+ #
252
+ # @return [Hash] min: (Float), max: (Float)
253
+ # @return [Hash] min: nil, max: nil (if invalid input)
254
+ def scheduleIntervalMinMax(sched)
255
+ mth = "OSut::#{__callee__}"
256
+ cl = OpenStudio::Model::ScheduleInterval
257
+ vals = []
258
+ prev_str = ""
259
+ res = { min: nil, max: nil }
260
+
261
+ return invalid("sched", mth, 1, DBG, res) unless sched.respond_to?(NS)
262
+ id = sched.nameString
263
+ return mismatch(id, sched, cl, mth, DBG, res) unless sched.is_a?(cl)
264
+
265
+ vals = sched.timeSeries.values
266
+ if vals.min.is_a?(Numeric) && vals.max.is_a?(Numeric)
267
+ res[:min] = vals.min
268
+ res[:max] = vals.max
269
+ else
270
+ log(ERR, "Non-numeric values in '#{id}' (#{mth})")
271
+ end
272
+
273
+ res
274
+ end
275
+
276
+ ##
277
+ # Return max zone heating temperature schedule setpoint [°C] and whether
278
+ # zone has active dual setpoint thermostat.
279
+ #
280
+ # @param zone [OpenStudio::Model::ThermalZone] a thermal zone
281
+ #
282
+ # @return [Hash] spt: (Float), dual: (Bool)
283
+ # @return [Hash] spt: nil, dual: false (if invalid input)
284
+ def maxHeatScheduledSetpoint(zone)
285
+ # Largely inspired from Parker & Marrec's "thermal_zone_heated?" procedure.
286
+ # The solution here is a tad more relaxed to encompass SEMI-HEATED zones as
287
+ # per Canadian NECB criteria (basically any space with at least 10 W/m2 of
288
+ # installed heating equipement, i.e. below freezing in Canada).
289
+ #
290
+ # github.com/NREL/openstudio-standards/blob/
291
+ # 58964222d25783e9da4ae292e375fb0d5c902aa5/lib/openstudio-standards/
292
+ # standards/Standards.ThermalZone.rb#L910
293
+ mth = "OSut::#{__callee__}"
294
+ cl = OpenStudio::Model::ThermalZone
295
+ res = { spt: nil, dual: false }
296
+
297
+ return invalid("zone", mth, 1, DBG, res) unless zone.respond_to?(NS)
298
+ id = zone.nameString
299
+ return mismatch(id, zone, cl, mth, DBG, res) unless zone.is_a?(cl)
300
+
301
+ # Zone radiant heating? Get schedule from radiant system.
302
+ zone.equipment.each do |equip|
303
+ sched = nil
304
+
305
+ unless equip.to_ZoneHVACHighTemperatureRadiant.empty?
306
+ equip = equip.to_ZoneHVACHighTemperatureRadiant.get
307
+
308
+ unless equip.heatingSetpointTemperatureSchedule.empty?
309
+ sched = equip.heatingSetpointTemperatureSchedule.get
310
+ end
311
+ end
312
+
313
+ unless equip.to_ZoneHVACLowTemperatureRadiantElectric.empty?
314
+ equip = equip.to_ZoneHVACLowTemperatureRadiantElectric.get
315
+
316
+ unless equip.heatingSetpointTemperatureSchedule.empty?
317
+ sched = equip.heatingSetpointTemperatureSchedule.get
318
+ end
319
+ end
320
+
321
+ unless equip.to_ZoneHVACLowTempRadiantConstFlow.empty?
322
+ equip = equip.to_ZoneHVACLowTempRadiantConstFlow.get
323
+ coil = equip.heatingCoil
324
+
325
+ unless coil.to_CoilHeatingLowTempRadiantConstFlow.empty?
326
+ coil = coil.to_CoilHeatingLowTempRadiantConstFlow.get
327
+
328
+ unless coil.heatingHighControlTemperatureSchedule.empty?
329
+ sched = c.heatingHighControlTemperatureSchedule.get
330
+ end
331
+ end
332
+ end
333
+
334
+ unless equip.to_ZoneHVACLowTempRadiantVarFlow.empty?
335
+ equip = equip.to_ZoneHVACLowTempRadiantVarFlow.get
336
+ coil = equip.heatingCoil
337
+
338
+ unless coil.to_CoilHeatingLowTempRadiantVarFlow.empty?
339
+ coil = coil.to_CoilHeatingLowTempRadiantVarFlow.get
340
+
341
+ unless coil.heatingControlTemperatureSchedule.empty?
342
+ sched = coil.heatingControlTemperatureSchedule.get
343
+ end
344
+ end
345
+ end
346
+
347
+ next unless sched
348
+
349
+ unless sched.to_ScheduleRuleset.empty?
350
+ sched = sched.to_ScheduleRuleset.get
351
+ max = scheduleRulesetMinMax(sched)[:max]
352
+
353
+ if max
354
+ res[:spt] = max unless res[:spt]
355
+ res[:spt] = max if res[:spt] < max
356
+ end
357
+ end
358
+
359
+ unless sched.to_ScheduleConstant.empty?
360
+ sched = sched.to_ScheduleConstant.get
361
+ max = scheduleConstantMinMax(sched)[:max]
362
+
363
+ if max
364
+ res[:spt] = max unless res[:spt]
365
+ res[:spt] = max if res[:spt] < max
366
+ end
367
+ end
368
+
369
+ unless sched.to_ScheduleCompact.empty?
370
+ sched = sched.to_ScheduleCompact.get
371
+ max = scheduleCompactMinMax(sched)[:max]
372
+
373
+ if max
374
+ res[:spt] = max unless res[:spt]
375
+ res[:spt] = max if res[:spt] < max
376
+ end
377
+ end
378
+ end
379
+
380
+ return res if res[:spt]
381
+ return res if zone.thermostat.empty?
382
+ tstat = zone.thermostat.get
383
+
384
+ unless tstat.to_ThermostatSetpointDualSetpoint.empty? &&
385
+ tstat.to_ZoneControlThermostatStagedDualSetpoint.empty?
386
+ res[:dual] = true
387
+
388
+ unless tstat.to_ThermostatSetpointDualSetpoint.empty?
389
+ tstat = tstat.to_ThermostatSetpointDualSetpoint.get
390
+ else
391
+ tstat = tstat.to_ZoneControlThermostatStagedDualSetpoint.get
392
+ end
393
+
394
+ unless tstat.heatingSetpointTemperatureSchedule.empty?
395
+ sched = tstat.heatingSetpointTemperatureSchedule.get
396
+
397
+ unless sched.to_ScheduleRuleset.empty?
398
+ sched = sched.to_ScheduleRuleset.get
399
+ max = scheduleRulesetMinMax(sched)[:max]
400
+
401
+ if max
402
+ res[:spt] = max unless res[:spt]
403
+ res[:spt] = max if res[:spt] < max
404
+ end
405
+
406
+ dd = sched.winterDesignDaySchedule
407
+
408
+ unless dd.values.empty?
409
+ res[:spt] = dd.values.max unless res[:spt]
410
+ res[:spt] = dd.values.max if res[:spt] < dd.values.max
411
+ end
412
+ end
413
+
414
+ unless sched.to_ScheduleConstant.empty?
415
+ sched = sched.to_ScheduleConstant.get
416
+ max = scheduleConstantMinMax(sched)[:max]
417
+
418
+ if max
419
+ res[:spt] = max unless res[:spt]
420
+ res[:spt] = max if res[:spt] < max
421
+ end
422
+ end
423
+
424
+ unless sched.to_ScheduleCompact.empty?
425
+ sched = sched.to_ScheduleCompact.get
426
+ max = scheduleCompactMinMax(sched)[:max]
427
+
428
+ if max
429
+ res[:spt] = max unless res[:spt]
430
+ res[:spt] = max if res[:spt] < max
431
+ end
432
+ end
433
+
434
+ unless sched.to_ScheduleYear.empty?
435
+ sched = sched.to_ScheduleYear.get
436
+
437
+ sched.getScheduleWeeks.each do |week|
438
+ next if week.winterDesignDaySchedule.empty?
439
+ dd = week.winterDesignDaySchedule.get
440
+ next unless dd.values.empty?
441
+
442
+ res[:spt] = dd.values.max unless res[:spt]
443
+ res[:spt] = dd.values.max if res[:spt] < dd.values.max
444
+ end
445
+ end
446
+ end
447
+ end
448
+
449
+ res
450
+ end
451
+
452
+ ##
453
+ # Validate if model has zones with valid heating temperature setpoints.
454
+ #
455
+ # @param model [OpenStudio::Model::Model] a model
456
+ #
457
+ # @return [Bool] true if valid heating temperature setpoints
458
+ # @return [Bool] false if invalid input
459
+ def heatingTemperatureSetpoints?(model)
460
+ mth = "OSut::#{__callee__}"
461
+ cl = OpenStudio::Model::Model
462
+
463
+ return invalid("model", mth, 1, DBG, false) unless model
464
+ return mismatch("model", model, cl, mth, DBG, false) unless model.is_a?(cl)
465
+
466
+ model.getThermalZones.each do |zone|
467
+ return true if maxHeatScheduledSetpoint(zone)[:spt]
468
+ end
469
+
470
+ false
471
+ end
472
+
473
+ ##
474
+ # Return min zone cooling temperature schedule setpoint [°C] and whether
475
+ # zone has active dual setpoint thermostat.
476
+ #
477
+ # @param zone [OpenStudio::Model::ThermalZone] a thermal zone
478
+ #
479
+ # @return [Hash] spt: (Float), dual: (Bool)
480
+ # @return [Hash] spt: nil, dual: false (if invalid input)
481
+ def minCoolScheduledSetpoint(zone)
482
+ # Largely inspired from Parker & Marrec's "thermal_zone_cooled?" procedure.
483
+ #
484
+ # github.com/NREL/openstudio-standards/blob/
485
+ # 99cf713750661fe7d2082739f251269c2dfd9140/lib/openstudio-standards/
486
+ # standards/Standards.ThermalZone.rb#L1058
487
+ mth = "OSut::#{__callee__}"
488
+ cl = OpenStudio::Model::ThermalZone
489
+ res = { spt: nil, dual: false }
490
+
491
+ return invalid("zone", mth, 1, DBG, res) unless zone.respond_to?(NS)
492
+ id = zone.nameString
493
+ return mismatch(id, zone, cl, mth, DBG, res) unless zone.is_a?(cl)
494
+
495
+ # Zone radiant cooling? Get schedule from radiant system.
496
+ zone.equipment.each do |equip|
497
+ sched = nil
498
+
499
+ unless equip.to_ZoneHVACLowTempRadiantConstFlow.empty?
500
+ equip = equip.to_ZoneHVACLowTempRadiantConstFlow.get
501
+ coil = equip.coolingCoil
502
+
503
+ unless coil.to_CoilCoolingLowTempRadiantConstFlow.empty?
504
+ coil = coil.to_CoilCoolingLowTempRadiantConstFlow.get
505
+
506
+ unless coil.coolingLowControlTemperatureSchedule.empty?
507
+ sched = coil.coolingLowControlTemperatureSchedule.get
508
+ end
509
+ end
510
+ end
511
+
512
+ unless equip.to_ZoneHVACLowTempRadiantVarFlow.empty?
513
+ equip = equip.to_ZoneHVACLowTempRadiantVarFlow.get
514
+ coil = equip.coolingCoil
515
+
516
+ unless coil.to_CoilCoolingLowTempRadiantVarFlow.empty?
517
+ coil = coil.to_CoilCoolingLowTempRadiantVarFlow.get
518
+
519
+ unless coil.coolingControlTemperatureSchedule.empty?
520
+ sched = coil.coolingControlTemperatureSchedule.get
521
+ end
522
+ end
523
+ end
524
+
525
+ next unless sched
526
+
527
+ unless sched.to_ScheduleRuleset.empty?
528
+ sched = sched.to_ScheduleRuleset.get
529
+ min = scheduleRulesetMinMax(sched)[:min]
530
+
531
+ if min
532
+ res[:spt] = min unless res[:spt]
533
+ res[:spt] = min if res[:spt] > min
534
+ end
535
+ end
536
+
537
+ unless sched.to_ScheduleConstant.empty?
538
+ sched = sched.to_ScheduleConstant.get
539
+ min = scheduleConstantMinMax(sched)[:min]
540
+
541
+ if min
542
+ res[:spt] = min unless res[:spt]
543
+ res[:spt] = min if res[:spt] > min
544
+ end
545
+ end
546
+
547
+ unless sched.to_ScheduleCompact.empty?
548
+ sched = sched.to_ScheduleCompact.get
549
+ min = scheduleCompactMinMax(sched)[:min]
550
+
551
+ if min
552
+ res[:spt] = min unless res[:spt]
553
+ res[:spt] = min if res[:spt] > min
554
+ end
555
+ end
556
+ end
557
+
558
+ return res if res[:spt]
559
+ return res if zone.thermostat.empty?
560
+ tstat = zone.thermostat.get
561
+
562
+ unless tstat.to_ThermostatSetpointDualSetpoint.empty? &&
563
+ tstat.to_ZoneControlThermostatStagedDualSetpoint.empty?
564
+ res[:dual] = true
565
+
566
+ unless tstat.to_ThermostatSetpointDualSetpoint.empty?
567
+ tstat = tstat.to_ThermostatSetpointDualSetpoint.get
568
+ else
569
+ tstat = tstat.to_ZoneControlThermostatStagedDualSetpoint.get
570
+ end
571
+
572
+ unless tstat.coolingSetpointTemperatureSchedule.empty?
573
+ sched = tstat.coolingSetpointTemperatureSchedule.get
574
+
575
+ unless sched.to_ScheduleRuleset.empty?
576
+ sched = sched.to_ScheduleRuleset.get
577
+ min = scheduleRulesetMinMax(sched)[:min]
578
+
579
+ if min
580
+ res[:spt] = min unless res[:spt]
581
+ res[:spt] = min if res[:spt] > min
582
+ end
583
+
584
+ dd = sched.summerDesignDaySchedule
585
+
586
+ unless dd.values.empty?
587
+ res[:spt] = dd.values.min unless res[:spt]
588
+ res[:spt] = dd.values.min if res[:spt] > dd.values.min
589
+ end
590
+ end
591
+
592
+ unless sched.to_ScheduleConstant.empty?
593
+ sched = sched.to_ScheduleConstant.get
594
+ min = scheduleConstantMinMax(sched)[:min]
595
+
596
+ if min
597
+ res[:spt] = min unless res[:spt]
598
+ res[:spt] = min if res[:spt] > min
599
+ end
600
+ end
601
+
602
+ unless sched.to_ScheduleCompact.empty?
603
+ sched = sched.to_ScheduleCompact.get
604
+ min = scheduleCompactMinMax(sched)[:min]
605
+
606
+ if min
607
+ res[:spt] = min unless res[:spt]
608
+ res[:spt] = min if res[:spt] > min
609
+ end
610
+ end
611
+
612
+ unless sched.to_ScheduleYear.empty?
613
+ sched = sched.to_ScheduleYear.get
614
+
615
+ sched.getScheduleWeeks.each do |week|
616
+ next if week.summerDesignDaySchedule.empty?
617
+ dd = week.summerDesignDaySchedule.get
618
+ next unless dd.values.empty?
619
+
620
+ res[:spt] = dd.values.min unless res[:spt]
621
+ res[:spt] = dd.values.min if res[:spt] > dd.values.min
622
+ end
623
+ end
624
+ end
625
+ end
626
+
627
+ res
628
+ end
629
+
630
+ ##
631
+ # Validate if model has zones with valid cooling temperature setpoints.
632
+ #
633
+ # @param model [OpenStudio::Model::Model] a model
634
+ #
635
+ # @return [Bool] true if valid cooling temperature setpoints
636
+ # @return [Bool] false if invalid input
637
+ def coolingTemperatureSetpoints?(model)
638
+ mth = "OSut::#{__callee__}"
639
+ cl = OpenStudio::Model::Model
640
+
641
+ return invalid("model", mth, 1, DBG, false) unless model
642
+ return mismatch("model", model, cl, mth, DBG, false) unless model.is_a?(cl)
643
+
644
+ model.getThermalZones.each do |zone|
645
+ return true if minCoolScheduledSetpoint(zone)[:spt]
646
+ end
647
+
648
+ false
649
+ end
650
+
651
+ ##
652
+ # Validate if model has zones with HVAC air loops.
653
+ #
654
+ # @param model [OpenStudio::Model::Model] a model
655
+ #
656
+ # @return [Bool] true if model has one or more HVAC air loops
657
+ # @return [Bool] false if invalid input
658
+ def airLoopsHVAC?(model)
659
+ mth = "OSut::#{__callee__}"
660
+ cl = OpenStudio::Model::Model
661
+
662
+ return invalid("model", mth, 1, DBG, false) unless model
663
+ return mismatch("model", model, cl, mth, DBG, false) unless model.is_a?(cl)
664
+
665
+ model.getThermalZones.each do |zone|
666
+ next if zone.canBePlenum
667
+ return true unless zone.airLoopHVACs.empty?
668
+ return true if zone.isPlenum
669
+ end
670
+
671
+ false
672
+ end
673
+
674
+ ##
675
+ # Validate whether space should be processed as a plenum.
676
+ #
677
+ # @param space [OpenStudio::Model::Space] a space
678
+ # @param loops [Bool] true if model has airLoopHVAC object(s)
679
+ # @param setpoints [Bool] true if model has valid temperature setpoints
680
+ #
681
+ # @return [Bool] true if should be tagged as plenum
682
+ # @return [Bool] false if invalid input
683
+ def plenum?(space, loops, setpoints)
684
+ # Largely inspired from NREL's "space_plenum?" procedure:
685
+ #
686
+ # github.com/NREL/openstudio-standards/blob/
687
+ # 58964222d25783e9da4ae292e375fb0d5c902aa5/lib/openstudio-standards/
688
+ # standards/Standards.Space.rb#L1384
689
+
690
+ # A space may be tagged as a plenum if:
691
+ #
692
+ # CASE A: its zone's "isPlenum" == true (SDK method) for a fully-developed
693
+ # OpenStudio model (complete with HVAC air loops);
694
+ #
695
+ # CASE B: it's excluded from building's total floor area yet linked to a
696
+ # zone holding an "inactive" thermostat (i.e., can't extract
697
+ # valid setpoints);
698
+ #
699
+ # CASE C: it has a spacetype whose name holds "plenum", or a spacetype with
700
+ # a 'standards spacetype' holding "plenum" (case insensitive); OR
701
+ #
702
+ # CASE D: its name string holds "plenum" (also case insensitive).
703
+
704
+ mth = "OSut::#{__callee__}"
705
+ cl = OpenStudio::Model::Space
706
+
707
+ return invalid("space", mth, 1, DBG, false) unless space.respond_to?(NS)
708
+ id = space.nameString
709
+ return mismatch(id, space, cl, mth, DBG, false) unless space.is_a?(cl)
710
+
711
+ valid = loops == true || loops == false
712
+ return invalid("loops", mth, 2, DBG, false) unless valid
713
+
714
+ valid = setpoints == true || setpoints == false
715
+ return invalid("setpoints", mth, 3, DBG, false) unless valid
716
+
717
+ unless space.thermalZone.empty?
718
+ zone = space.thermalZone.get
719
+ return true if zone.isPlenum && loops # CASE A
720
+
721
+ if setpoints
722
+ heating = maxHeatScheduledSetpoint(zone)
723
+ cooling = minCoolScheduledSetpoint(zone)
724
+
725
+ return false if heating[:spt] || cooling[:spt] # directly conditioned
726
+
727
+ unless space.partofTotalFloorArea
728
+ return true if heating[:dual] || cooling[:dual] # CASE B
729
+ end
730
+ end
731
+ end
732
+
733
+ unless space.spaceType.empty?
734
+ type = space.spaceType.get
735
+ return true if type.nameString.downcase.include?("plenum") # CASE C
736
+
737
+ unless type.standardsSpaceType.empty?
738
+ type = type.standardsSpaceType.get
739
+ return true if type.downcase.include?("plenum") # CASE C
740
+ end
741
+ end
742
+
743
+ return true if space.nameString.downcase.include?("plenum")
744
+
745
+ false
746
+ end
747
+
748
+ ##
749
+ # Generate an HVAC availability schedule.
750
+ #
751
+ # @param model [OpenStudio::Model::Model] a model
752
+ # @param avl [String] seasonal availability choice (optional, default "ON")
753
+ #
754
+ # @return [OpenStudio::Model::Schedule] HVAC availability sched
755
+ # @return [nil] if invalid input
756
+ def availabilitySchedule(model, avl = "")
757
+ mth = "OSut::#{__callee__}"
758
+ cl = OpenStudio::Model::Model
759
+
760
+ return invalid("model", mth, 1) unless model
761
+ return mismatch("model", model, cl, mth) unless model.is_a?(cl)
762
+
763
+ # Either fetch availability ScheduleTypeLimits object, or create one.
764
+ limits = nil
765
+
766
+ model.getScheduleTypeLimitss.each do |l|
767
+ break if limits
768
+ next if l.lowerLimitValue.empty?
769
+ next if l.upperLimitValue.empty?
770
+ next if l.numericType.empty?
771
+ next unless l.lowerLimitValue.get.to_i == 0
772
+ next unless l.upperLimitValue.get.to_i == 1
773
+ next unless l.numericType.get.downcase == "discrete"
774
+ next unless l.unitType.downcase == "availability"
775
+ next unless l.nameString.downcase == "hvac operation scheduletypelimits"
776
+ limits = l
777
+ end
778
+
779
+ unless limits
780
+ limits = OpenStudio::Model::ScheduleTypeLimits.new(model)
781
+ limits.setName("HVAC Operation ScheduleTypeLimits")
782
+ limits.setLowerLimitValue(0)
783
+ limits.setUpperLimitValue(1)
784
+ limits.setNumericType("Discrete")
785
+ limits.setUnitType("Availability")
786
+ end
787
+
788
+ time = OpenStudio::Time.new(0,24)
789
+ secs = time.totalSeconds
790
+ on = OpenStudio::Model::ScheduleDay.new(model, 1)
791
+ off = OpenStudio::Model::ScheduleDay.new(model, 0)
792
+
793
+ # Seasonal availability start/end dates.
794
+ year = model.yearDescription
795
+ return empty("yearDescription", mth, ERR) if year.empty?
796
+ year = year.get
797
+ may01 = year.makeDate(OpenStudio::MonthOfYear.new("May"), 1)
798
+ oct31 = year.makeDate(OpenStudio::MonthOfYear.new("Oct"), 31)
799
+
800
+ case avl.downcase
801
+ when "winter" # available from November 1 to April 30 (6 months)
802
+ val = 1
803
+ sch = off
804
+ nom = "WINTER Availability SchedRuleset"
805
+ dft = "WINTER Availability dftDaySched"
806
+ tag = "May-Oct WINTER Availability SchedRule"
807
+ day = "May-Oct WINTER SchedRule Day"
808
+ when "summer" # available from May 1 to October 31 (6 months)
809
+ val = 0
810
+ sch = on
811
+ nom = "SUMMER Availability SchedRuleset"
812
+ dft = "SUMMER Availability dftDaySched"
813
+ tag = "May-Oct SUMMER Availability SchedRule"
814
+ day = "May-Oct SUMMER SchedRule Day"
815
+ when "off" # never available
816
+ val = 0
817
+ sch = on
818
+ nom = "OFF Availability SchedRuleset"
819
+ dft = "OFF Availability dftDaySched"
820
+ tag = ""
821
+ day = ""
822
+ else # always available
823
+ val = 1
824
+ sch = on
825
+ nom = "ON Availability SchedRuleset"
826
+ dft = "ON Availability dftDaySched"
827
+ tag = ""
828
+ day = ""
829
+ end
830
+
831
+ # Fetch existing schedule.
832
+ ok = true
833
+ schedule = model.getScheduleByName(nom)
834
+
835
+ unless schedule.empty?
836
+ schedule = schedule.get.to_ScheduleRuleset
837
+
838
+ unless schedule.empty?
839
+ schedule = schedule.get
840
+ default = schedule.defaultDaySchedule
841
+ ok = ok && default.nameString == dft
842
+ ok = ok && default.times.size == 1
843
+ ok = ok && default.values.size == 1
844
+ ok = ok && default.times.first == time
845
+ ok = ok && default.values.first == val
846
+ rules = schedule.scheduleRules
847
+ ok = ok && (rules.size == 0 || rules.size == 1)
848
+
849
+ if rules.size == 1
850
+ rule = rules.first
851
+ ok = ok && rule.nameString == tag
852
+ ok = ok && !rule.startDate.empty?
853
+ ok = ok && !rule.endDate.empty?
854
+ ok = ok && rule.startDate.get == may01
855
+ ok = ok && rule.endDate.get == oct31
856
+ ok = ok && rule.applyAllDays
857
+
858
+ d = rule.daySchedule
859
+ ok = ok && d.nameString == day
860
+ ok = ok && d.times.size == 1
861
+ ok = ok && d.values.size == 1
862
+ ok = ok && d.times.first.totalSeconds == secs
863
+ ok = ok && d.values.first.to_i != val
864
+ end
865
+
866
+ return schedule if ok
867
+ end
868
+ end
869
+
870
+ schedule = OpenStudio::Model::ScheduleRuleset.new(model)
871
+ schedule.setName(nom)
872
+
873
+ unless schedule.setScheduleTypeLimits(limits)
874
+ log(ERR, "'#{nom}': Can't set schedule type limits (#{mth})")
875
+ return nil
876
+ end
877
+
878
+ unless schedule.defaultDaySchedule.addValue(time, val)
879
+ log(ERR, "'#{nom}': Can't set default day schedule (#{mth})")
880
+ return nil
881
+ end
882
+
883
+ schedule.defaultDaySchedule.setName(dft)
884
+
885
+ unless tag.empty?
886
+ rule = OpenStudio::Model::ScheduleRule.new(schedule, sch)
887
+ rule.setName(tag)
888
+
889
+ unless rule.setStartDate(may01)
890
+ log(ERR, "'#{tag}': Can't set start date (#{mth})")
891
+ return nil
892
+ end
893
+
894
+ unless rule.setEndDate(oct31)
895
+ log(ERR, "'#{tag}': Can't set end date (#{mth})")
896
+ return nil
897
+ end
898
+
899
+ unless rule.setApplyAllDays(true)
900
+ log(ERR, "'#{tag}': Can't apply to all days (#{mth})")
901
+ return nil
902
+ end
903
+
904
+ rule.daySchedule.setName(day)
905
+ end
906
+
907
+ schedule
908
+ end
909
+
910
+ ##
911
+ # Validate if default construction set holds a base ground construction.
912
+ #
913
+ # @param set [OpenStudio::Model::DefaultConstructionSet] a default set
914
+ # @param base [OpensStudio::Model::ConstructionBase] a construction base
915
+ # @param ground [Bool] true if ground-facing surface
916
+ # @param exterior [Bool] true if exterior-facing surface
917
+ # @param type [String] a surface type
918
+ #
919
+ # @return [Bool] true if default construction set holds construction
920
+ # @return [Bool] false if invalid input
921
+ def holdsConstruction?(set, base, ground = false, exterior = false, type = "")
922
+ mth = "OSut::#{__callee__}"
923
+ cl1 = OpenStudio::Model::DefaultConstructionSet
924
+ cl2 = OpenStudio::Model::ConstructionBase
925
+
926
+ return invalid("set", mth, 1, DBG, false) unless set.respond_to?(NS)
927
+ id = set.nameString
928
+ return mismatch(id, set, cl1, mth, DBG, false) unless set.is_a?(cl1)
929
+
930
+ return invalid("base", mth, 2, DBG, false) unless base.respond_to?(NS)
931
+ id = base.nameString
932
+ return mismatch(id, base, cl2, mth, DBG, false) unless base.is_a?(cl2)
933
+
934
+ valid = ground == true || ground == false
935
+ return invalid("ground", mth, 3, DBG, false) unless valid
936
+
937
+ valid = exterior == true || exterior == false
938
+ return invalid("exterior", mth, 4, DBG, false) unless valid
939
+
940
+ typ = type.to_s.downcase
941
+ valid = typ == "floor" || typ == "wall" || typ == "roofceiling"
942
+ return invalid("surface type", mth, 5, DBG, false) unless valid
943
+
944
+ constructions = nil
945
+
946
+ if ground
947
+ unless set.defaultGroundContactSurfaceConstructions.empty?
948
+ constructions = set.defaultGroundContactSurfaceConstructions.get
949
+ end
950
+ elsif exterior
951
+ unless set.defaultExteriorSurfaceConstructions.empty?
952
+ constructions = set.defaultExteriorSurfaceConstructions.get
953
+ end
954
+ else
955
+ unless set.defaultInteriorSurfaceConstructions.empty?
956
+ constructions = set.defaultInteriorSurfaceConstructions.get
957
+ end
958
+ end
959
+
960
+ return false unless constructions
961
+
962
+ case typ
963
+ when "roofceiling"
964
+ unless constructions.roofCeilingConstruction.empty?
965
+ construction = constructions.roofCeilingConstruction.get
966
+ return true if construction == base
967
+ end
968
+ when "floor"
969
+ unless constructions.floorConstruction.empty?
970
+ construction = constructions.floorConstruction.get
971
+ return true if construction == base
972
+ end
973
+ else
974
+ unless constructions.wallConstruction.empty?
975
+ construction = constructions.wallConstruction.get
976
+ return true if construction == base
977
+ end
978
+ end
979
+
980
+ false
981
+ end
982
+
983
+ ##
984
+ # Return a surface's default construction set.
985
+ #
986
+ # @param model [OpenStudio::Model::Model] a model
987
+ # @param s [OpenStudio::Model::Surface] a surface
988
+ #
989
+ # @return [OpenStudio::Model::DefaultConstructionSet] default set
990
+ # @return [nil] if invalid input
991
+ def defaultConstructionSet(model, s)
992
+ mth = "OSut::#{__callee__}"
993
+ cl1 = OpenStudio::Model::Model
994
+ cl2 = OpenStudio::Model::Surface
995
+
996
+ return invalid("model", mth, 1) unless model
997
+ return mismatch("model", model, cl1, mth) unless model.is_a?(cl1)
998
+
999
+ return invalid("s", mth, 2) unless s.respond_to?(NS)
1000
+ id = s.nameString
1001
+ return mismatch(id, s, cl2, mth) unless s.is_a?(cl2)
1002
+
1003
+ unless s.isConstructionDefaulted
1004
+ log(ERR, "'#{id}' construction not defaulted (#{mth})")
1005
+ return nil
1006
+ end
1007
+
1008
+ return empty("'#{id}' construction", mth, ERR) if s.construction.empty?
1009
+ base = s.construction.get
1010
+ return empty("'#{id}' space", mth, ERR) if s.space.empty?
1011
+ space = s.space.get
1012
+ type = s.surfaceType
1013
+
1014
+ ground = false
1015
+ exterior = false
1016
+
1017
+ if s.isGroundSurface
1018
+ ground = true
1019
+ elsif s.outsideBoundaryCondition.downcase == "outdoors"
1020
+ exterior = true
1021
+ end
1022
+
1023
+ unless space.defaultConstructionSet.empty?
1024
+ set = space.defaultConstructionSet.get
1025
+ return set if holdsConstruction?(set, base, ground, exterior, type)
1026
+ end
1027
+
1028
+ unless space.spaceType.empty?
1029
+ spacetype = space.spaceType.get
1030
+
1031
+ unless spacetype.defaultConstructionSet.empty?
1032
+ set = spacetype.defaultConstructionSet.get
1033
+ return set if holdsConstruction?(set, base, ground, exterior, type)
1034
+ end
1035
+ end
1036
+
1037
+ unless space.buildingStory.empty?
1038
+ story = space.buildingStory.get
1039
+
1040
+ unless story.defaultConstructionSet.empty?
1041
+ set = story.defaultConstructionSet.get
1042
+ return set if holdsConstruction?(set, base, ground, exterior, type)
1043
+ end
1044
+ end
1045
+
1046
+ building = model.getBuilding
1047
+
1048
+ unless building.defaultConstructionSet.empty?
1049
+ set = building.defaultConstructionSet.get
1050
+ return set if holdsConstruction?(set, base, ground, exterior, type)
1051
+ end
1052
+
1053
+ nil
1054
+ end
1055
+
1056
+ ##
1057
+ # Validate if every material in a layered construction is standard & opaque.
1058
+ #
1059
+ # @param lc [OpenStudio::LayeredConstruction] a layered construction
1060
+ #
1061
+ # @return [Bool] true if all layers are valid
1062
+ # @return [Bool] false if invalid input
1063
+ def standardOpaqueLayers?(lc)
1064
+ mth = "OSut::#{__callee__}"
1065
+ cl = OpenStudio::Model::LayeredConstruction
1066
+
1067
+ return invalid("lc", mth, 1, DBG, false) unless lc.respond_to?(NS)
1068
+ return mismatch(lc.nameString, lc, cl, mth, DBG, false) unless lc.is_a?(cl)
1069
+
1070
+ lc.layers.each { |m| return false if m.to_StandardOpaqueMaterial.empty? }
1071
+ true
1072
+ end
1073
+
1074
+ ##
1075
+ # Total (standard opaque) layered construction thickness (in m).
1076
+ #
1077
+ # @param lc [OpenStudio::LayeredConstruction] a layered construction
1078
+ #
1079
+ # @return [Double] total layered construction thickness
1080
+ # @return [Double] 0 if invalid input
1081
+ def thickness(lc)
1082
+ mth = "OSut::#{__callee__}"
1083
+ cl = OpenStudio::Model::LayeredConstruction
1084
+
1085
+ return invalid("lc", mth, 1, DBG, 0) unless lc.respond_to?(NS)
1086
+ id = lc.nameString
1087
+ return mismatch(id, lc, cl, mth, DBG, 0) unless lc.is_a?(cl)
1088
+
1089
+ unless standardOpaqueLayers?(lc)
1090
+ log(ERR, "'#{id}' holds non-StandardOpaqueMaterial(s) (#{mth})")
1091
+ return 0
1092
+ end
1093
+
1094
+ thickness = 0.0
1095
+ lc.layers.each { |m| thickness += m.thickness }
1096
+ thickness
1097
+ end
1098
+
1099
+ ##
1100
+ # Return total air film resistance for fenestration.
1101
+ #
1102
+ # @param usi [Float] a fenestrated construction's U-factor (W/m2•K)
1103
+ #
1104
+ # @return [Float] total air film resistance in m2•K/W (0.1216 if errors)
1105
+ def glazingAirFilmRSi(usi = 5.85)
1106
+ # The sum of thermal resistances of calculated exterior and interior film
1107
+ # coefficients under standard winter conditions are taken from:
1108
+ #
1109
+ # https://bigladdersoftware.com/epx/docs/9-6/engineering-reference/
1110
+ # window-calculation-module.html#simple-window-model
1111
+ #
1112
+ # These remain acceptable approximations for flat windows, yet likely
1113
+ # unsuitable for subsurfaces with curved or projecting shapes like domed
1114
+ # skylights. The solution here is considered an adequate fix for reporting,
1115
+ # awaiting eventual OpenStudio (and EnergyPlus) upgrades to report NFRC 100
1116
+ # (or ISO) air film resistances under standard winter conditions.
1117
+ #
1118
+ # For U-factors above 8.0 W/m2•K (or invalid input), the function returns
1119
+ # 0.1216 m2•K/W, which corresponds to a construction with a single glass
1120
+ # layer thickness of 2mm & k = ~0.6 W/m.K.
1121
+ #
1122
+ # The EnergyPlus Engineering calculations were designed for vertical windows
1123
+ # - not horizontal, slanted or domed surfaces - use with caution.
1124
+ mth = "OSut::#{__callee__}"
1125
+ cl = Numeric
1126
+
1127
+ return invalid("usi", mth, 1, DBG, 0.1216) unless usi
1128
+ return mismatch("usi", usi, cl, mth, DBG, 0.1216) unless usi.is_a?(cl)
1129
+ return invalid("usi", mth, 1, WRN, 0.1216) if usi > 8.0
1130
+
1131
+ rsi = 1 / (0.025342 * usi + 29.163853) # exterior film, next interior film
1132
+
1133
+ return rsi + 1 / (0.359073 * Math.log(usi) + 6.949915) if usi < 5.85
1134
+ return rsi + 1 / (1.788041 * usi - 2.886625)
1135
+ end
1136
+
1137
+ ##
1138
+ # Return a construction's 'standard calc' thermal resistance (with air films).
1139
+ #
1140
+ # @param lc [OpenStudio::Model::LayeredConstruction] a layered construction
1141
+ # @param film [Float] thermal resistance of surface air films (m2•K/W)
1142
+ # @param t [Float] gas temperature (°C) (optional)
1143
+ #
1144
+ # @return [Float] calculated RSi at standard conditions (0 if error)
1145
+ def rsi(lc, film, t = 0.0)
1146
+ # This is adapted from BTAP's Material Module's "get_conductance" (P. Lopez)
1147
+ #
1148
+ # https://github.com/NREL/OpenStudio-Prototype-Buildings/blob/
1149
+ # c3d5021d8b7aef43e560544699fb5c559e6b721d/lib/btap/measures/
1150
+ # btap_equest_converter/envelope.rb#L122
1151
+
1152
+ mth = "OSut::#{__callee__}"
1153
+ cl1 = OpenStudio::Model::LayeredConstruction
1154
+ cl2 = Numeric
1155
+
1156
+ return invalid("lc", mth, 1, DBG, 0) unless lc.respond_to?(NS)
1157
+ id = lc.nameString
1158
+ return mismatch(id, lc, cl1, mth, DBG, 0) unless lc.is_a?(cl1)
1159
+
1160
+ return invalid("film", mth, 2, DBG, 0) unless film
1161
+ return invalid("temperature", mth, 3, DBG, 0) unless t
1162
+
1163
+ return mismatch("film", film, cl2, mth, DBG, 0) unless film.is_a?(cl2)
1164
+ return mismatch("temperature", t, cl2, mth, DBG, 0) unless t.is_a?(cl2)
1165
+
1166
+ tt = t + 273.0 # °C to K
1167
+ return negative("temp K", mth, DBG, 0) if tt < 0
1168
+ return negative("film", mth, DBG, 0) if film < 0
1169
+
1170
+ rsi = film
1171
+
1172
+ lc.layers.each do |m|
1173
+ # Fenestration materials first (ignoring shades, screens, etc.)
1174
+ unless m.to_SimpleGlazing.empty?
1175
+ return 1 / m.to_SimpleGlazing.get.uFactor # no need to loop
1176
+ end
1177
+ unless m.to_StandardGlazing.empty?
1178
+ rsi += m.to_StandardGlazing.get.thermalResistance
1179
+ end
1180
+ unless m.to_RefractionExtinctionGlazing.empty?
1181
+ rsi += m.to_RefractionExtinctionGlazing.get.thermalResistance
1182
+ end
1183
+ unless m.to_Gas.empty?
1184
+ rsi += m.to_Gas.get.getThermalResistance(tt)
1185
+ end
1186
+ unless m.to_GasMixture.empty?
1187
+ rsi += m.to_GasMixture.get.getThermalResistance(tt)
1188
+ end
1189
+
1190
+ # Opaque materials next.
1191
+ unless m.to_StandardOpaqueMaterial.empty?
1192
+ rsi += m.to_StandardOpaqueMaterial.get.thermalResistance
1193
+ end
1194
+ unless m.to_MasslessOpaqueMaterial.empty?
1195
+ rsi += m.to_MasslessOpaqueMaterial.get.thermalResistance
1196
+ end
1197
+ unless m.to_RoofVegetation.empty?
1198
+ rsi += m.to_RoofVegetation.get.thermalResistance
1199
+ end
1200
+ unless m.to_AirGap.empty?
1201
+ rsi += m.to_AirGap.get.thermalResistance
1202
+ end
1203
+ end
1204
+
1205
+ rsi
1206
+ end
1207
+
1208
+ ##
1209
+ # Identify a layered construction's (opaque) insulating layer. The method
1210
+ # returns a 3-keyed hash ... :index (insulating layer index within layered
1211
+ # construction), :type (standard: or massless: material type), and
1212
+ # :r (material thermal resistance in m2•K/W).
1213
+ #
1214
+ # @param lc [OpenStudio::Model::LayeredConstruction] a layered construction
1215
+ #
1216
+ # @return [Hash] index: (Integer), type: (:standard or :massless), r: (Float)
1217
+ # @return [Hash] index: nil, type: nil, r: 0 (if invalid input)
1218
+ def insulatingLayer(lc)
1219
+ mth = "OSut::#{__callee__}"
1220
+ cl = OpenStudio::Model::LayeredConstruction
1221
+ res = { index: nil, type: nil, r: 0.0 }
1222
+ i = 0 # iterator
1223
+
1224
+ return invalid("lc", mth, 1, DBG, res) unless lc.respond_to?(NS)
1225
+ id = lc.nameString
1226
+ return mismatch(id, lc, cl1, mth, DBG, res) unless lc.is_a?(cl)
1227
+
1228
+ lc.layers.each do |m|
1229
+
1230
+ unless m.to_MasslessOpaqueMaterial.empty?
1231
+ m = m.to_MasslessOpaqueMaterial.get
1232
+
1233
+ if m.thermalResistance < 0.001 || m.thermalResistance < res[:r]
1234
+ i += 1
1235
+ next
1236
+ else
1237
+ res[:r] = m.thermalResistance
1238
+ res[:index] = i
1239
+ res[:type] = :massless
1240
+ end
1241
+ end
1242
+
1243
+ unless m.to_StandardOpaqueMaterial.empty?
1244
+ m = m.to_StandardOpaqueMaterial.get
1245
+ k = m.thermalConductivity
1246
+ d = m.thickness
1247
+
1248
+ if d < 0.003 || k > 3.0 || d / k < res[:r]
1249
+ i += 1
1250
+ next
1251
+ else
1252
+ res[:r] = d / k
1253
+ res[:index] = i
1254
+ res[:type] = :standard
1255
+ end
1256
+ end
1257
+
1258
+ i += 1
1259
+ end
1260
+
1261
+ res
1262
+ end
1263
+
1264
+ ##
1265
+ # Return OpenStudio site/space transformation & rotation angle [0,2PI) rads.
1266
+ #
1267
+ # @param model [OpenStudio::Model::Model] a model
1268
+ # @param group [OpenStudio::Model::PlanarSurfaceGroup] a group
1269
+ #
1270
+ # @return [Hash] t: (OpenStudio::Transformation), r: Float
1271
+ # @return [Hash] t: nil, r: nil (if invalid input)
1272
+ def transforms(model, group)
1273
+ mth = "OSut::#{__callee__}"
1274
+ cl1 = OpenStudio::Model::Model
1275
+ cl2 = OpenStudio::Model::PlanarSurfaceGroup
1276
+ res = { t: nil, r: nil }
1277
+
1278
+ return invalid("model", mth, 1, DBG, res) unless model
1279
+ return mismatch("model", model, cl1, mth, DBG, res) unless model.is_a?(cl1)
1280
+
1281
+ return invalid("group", mth, 2, DBG, res) unless group.respond_to?(NS)
1282
+ id = group.nameString
1283
+ return mismatch(id, group, cl2, mth, DBG, res) unless group.is_a?(cl2)
1284
+
1285
+ res[:t] = group.siteTransformation
1286
+ res[:r] = group.directionofRelativeNorth + model.getBuilding.northAxis
1287
+
1288
+ res
1289
+ end
1290
+
1291
+ ##
1292
+ # Flatten OpenStudio 3D points vs Z-axis (Z=0).
1293
+ #
1294
+ # @param pts [Array] an OpenStudio Point3D array/vector
1295
+ #
1296
+ # @return [Array] flattened OpenStudio 3D points
1297
+ def flatZ(pts)
1298
+ mth = "OSut::#{__callee__}"
1299
+ cl1 = OpenStudio::Point3dVector
1300
+ cl2 = OpenStudio::Point3d
1301
+ v = OpenStudio::Point3dVector.new
1302
+
1303
+ return invalid("points", mth, 1, DBG, v) unless pts
1304
+ valid = pts.is_a?(cl1) || pts.is_a?(Array)
1305
+ return mismatch("points", pts, cl1, mth, DBG, v) unless valid
1306
+
1307
+ pts.each { |pt| mismatch("pt", pt, cl2, mth, ERR, v) unless pt.is_a?(cl2) }
1308
+ pts.each { |pt| v << OpenStudio::Point3d.new(pt.x, pt.y, 0) }
1309
+
1310
+ v
1311
+ end
1312
+
1313
+ ##
1314
+ # Validate whether 1st OpenStudio convex polygon fits in 2nd convex polygon.
1315
+ #
1316
+ # @param p1 [OpenStudio::Point3dVector] or Point3D array of polygon #1
1317
+ # @param p2 [OpenStudio::Point3dVector] or Point3D array of polygon #2
1318
+ # @param id1 [String] polygon #1 identifier (optional)
1319
+ # @param id2 [String] polygon #2 identifier (optional)
1320
+ #
1321
+ # @return [Bool] true if 1st polygon fits entirely within the 2nd polygon
1322
+ # @return [Bool] false if invalid input
1323
+ def fits?(p1, p2, id1 = "", id2 = "")
1324
+ mth = "OSut::#{__callee__}"
1325
+ cl1 = OpenStudio::Point3dVector
1326
+ cl2 = OpenStudio::Point3d
1327
+ a = false
1328
+ i1 = id1.to_s
1329
+ i2 = id2.to_s
1330
+ i1 = "poly1" if i1.empty?
1331
+ i2 = "poly2" if i2.empty?
1332
+
1333
+ return invalid(i1, mth, 1, DBG, a) unless p1
1334
+ valid = p1.is_a?(cl1) || p1.is_a?(Array)
1335
+ return mismatch(i1, p1, cl1, mth, DBG, a) unless valid
1336
+ return empty(i1, mth, ERR, a) if p1.empty?
1337
+
1338
+ return invalid(i2, mth, 2, DBG, a) unless p2
1339
+ valid = p2.is_a?(cl1) || p2.is_a?(Array)
1340
+ return mismatch(i2, p2, cl1, mth, DBG, a) unless valid
1341
+ return empty(i2, mth, ERR, a) if p2.empty?
1342
+
1343
+ p1.each { |v| return mismatch(i1, v, cl2, mth, ERR, a) unless v.is_a?(cl2) }
1344
+ p2.each { |v| return mismatch(i2, v, cl2, mth, ERR, a) unless v.is_a?(cl2) }
1345
+
1346
+ ft = OpenStudio::Transformation::alignFace(p1).inverse
1347
+
1348
+ ft_p1 = flatZ( (ft * p1).reverse )
1349
+ return false if ft_p1.empty?
1350
+ area1 = OpenStudio::getArea(ft_p1)
1351
+ return empty(i1, mth, ERR, a) if area1.empty?
1352
+ area1 = area1.get
1353
+
1354
+ ft_p2 = flatZ( (ft * p2).reverse )
1355
+ return false if ft_p2.empty?
1356
+ area2 = OpenStudio::getArea(ft_p2)
1357
+ return empty(i2, mth, ERR, a) if area2.empty?
1358
+ area2 = area2.get
1359
+
1360
+ union = OpenStudio::join(ft_p1, ft_p2, TOL2)
1361
+ return false if union.empty?
1362
+ union = union.get
1363
+ area = OpenStudio::getArea(union)
1364
+ return empty("union", mth, ERR, a) if area.empty?
1365
+ area = area.get
1366
+
1367
+ return false if area < TOL
1368
+ return true if (area - area2).abs < TOL
1369
+ return false if (area - area2).abs > TOL
1370
+ true
1371
+ end
1372
+
1373
+ ##
1374
+ # Validate whether an OpenStudio polygon overlaps another.
1375
+ #
1376
+ # @param p1 [OpenStudio::Point3dVector] or Point3D array of polygon #1
1377
+ # @param p2 [OpenStudio::Point3dVector] or Point3D array of polygon #2
1378
+ # @param id1 [String] polygon #1 identifier (optional)
1379
+ # @param id2 [String] polygon #2 identifier (optional)
1380
+ #
1381
+ # @return Returns true if polygons overlaps (or either fits into the other)
1382
+ # @return [Bool] false if invalid input
1383
+ def overlaps?(p1, p2, id1 = "", id2 = "")
1384
+ mth = "OSut::#{__callee__}"
1385
+ cl1 = OpenStudio::Point3dVector
1386
+ cl2 = OpenStudio::Point3d
1387
+ a = false
1388
+ i1 = id1.to_s
1389
+ i2 = id2.to_s
1390
+ i1 = "poly1" if i1.empty?
1391
+ i2 = "poly2" if i2.empty?
1392
+
1393
+ return invalid(i1, mth, 1, DBG, a) unless p1
1394
+ valid = p1.is_a?(cl1) || p1.is_a?(Array)
1395
+ return mismatch(i1, p1, cl1, mth, DBG, a) unless valid
1396
+ return empty(i1, mth, ERR, a) if p1.empty?
1397
+
1398
+ return invalid(i2, mth, 2, DBG, a) unless p2
1399
+ valid = p2.is_a?(cl1) || p2.is_a?(Array)
1400
+ return mismatch(i2, p2, cl1, mth, DBG, a) unless valid
1401
+ return empty(i2, mth, ERR, a) if p2.empty?
1402
+
1403
+ p1.each { |v| return mismatch(i1, v, cl2, mth, ERR, a) unless v.is_a?(cl2) }
1404
+ p2.each { |v| return mismatch(i2, v, cl2, mth, ERR, a) unless v.is_a?(cl2) }
1405
+
1406
+ ft = OpenStudio::Transformation::alignFace(p1).inverse
1407
+
1408
+ ft_p1 = flatZ( (ft * p1).reverse )
1409
+ return false if ft_p1.empty?
1410
+ area1 = OpenStudio::getArea(ft_p1)
1411
+ return empty(i1, mth, ERR, a) if area1.empty?
1412
+ area1 = area1.get
1413
+
1414
+ ft_p2 = flatZ( (ft * p2).reverse )
1415
+ return false if ft_p2.empty?
1416
+ area2 = OpenStudio::getArea(ft_p2)
1417
+ return empty(i2, mth, ERR, a) if area2.empty?
1418
+ area2 = area2.get
1419
+
1420
+ union = OpenStudio::join(ft_p1, ft_p2, TOL2)
1421
+ return false if union.empty?
1422
+ union = union.get
1423
+ area = OpenStudio::getArea(union)
1424
+ return empty("union", mth, ERR, a) if area.empty?
1425
+ area = area.get
1426
+
1427
+ return false if area < TOL
1428
+ true
1429
+ end
1430
+ end