osut 0.2.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 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