tbd 3.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (45) hide show
  1. checksums.yaml +7 -0
  2. data/.gitattributes +3 -0
  3. data/.github/workflows/pull_request.yml +72 -0
  4. data/.gitignore +23 -0
  5. data/.rspec +3 -0
  6. data/Gemfile +3 -0
  7. data/LICENSE.md +21 -0
  8. data/README.md +154 -0
  9. data/Rakefile +60 -0
  10. data/json/midrise.json +64 -0
  11. data/json/tbd_5ZoneNoHVAC.json +19 -0
  12. data/json/tbd_5ZoneNoHVAC_btap.json +91 -0
  13. data/json/tbd_seb_n2.json +41 -0
  14. data/json/tbd_seb_n4.json +57 -0
  15. data/json/tbd_warehouse10.json +24 -0
  16. data/json/tbd_warehouse5.json +37 -0
  17. data/lib/measures/tbd/LICENSE.md +21 -0
  18. data/lib/measures/tbd/README.md +136 -0
  19. data/lib/measures/tbd/README.md.erb +42 -0
  20. data/lib/measures/tbd/docs/.gitkeep +1 -0
  21. data/lib/measures/tbd/measure.rb +327 -0
  22. data/lib/measures/tbd/measure.xml +460 -0
  23. data/lib/measures/tbd/resources/geo.rb +714 -0
  24. data/lib/measures/tbd/resources/geometry.rb +351 -0
  25. data/lib/measures/tbd/resources/model.rb +1431 -0
  26. data/lib/measures/tbd/resources/oslog.rb +381 -0
  27. data/lib/measures/tbd/resources/psi.rb +2229 -0
  28. data/lib/measures/tbd/resources/tbd.rb +55 -0
  29. data/lib/measures/tbd/resources/transformation.rb +121 -0
  30. data/lib/measures/tbd/resources/ua.rb +986 -0
  31. data/lib/measures/tbd/resources/utils.rb +1636 -0
  32. data/lib/measures/tbd/resources/version.rb +3 -0
  33. data/lib/measures/tbd/tests/tbd_full_PSI.json +17 -0
  34. data/lib/measures/tbd/tests/tbd_tests.rb +222 -0
  35. data/lib/tbd/geo.rb +714 -0
  36. data/lib/tbd/psi.rb +2229 -0
  37. data/lib/tbd/ua.rb +986 -0
  38. data/lib/tbd/version.rb +25 -0
  39. data/lib/tbd.rb +93 -0
  40. data/sponsors/canada.png +0 -0
  41. data/sponsors/quebec.png +0 -0
  42. data/tbd.gemspec +43 -0
  43. data/tbd.schema.json +571 -0
  44. data/v291_MacOS.md +110 -0
  45. metadata +191 -0
@@ -0,0 +1,1636 @@
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
+ DBG = OSut::DEBUG # mainly to flag invalid arguments to devs (buggy code)
39
+ INF = OSut::INFO # not currently used in OSut
40
+ WRN = OSut::WARN # WARN users of 'iffy' .osm inputs (yet not critical)
41
+ ERR = OSut::ERROR # flag invalid .osm inputs (then exit via 'return')
42
+ FTL = OSut::FATAL # not currently used in OSut
43
+ NS = "nameString" # OpenStudio IdfObject nameString method
44
+
45
+ # This first set of utilities (~750 lines) help distinguishing spaces that
46
+ # are directly vs indirectly CONDITIONED, vs SEMI-HEATED. The solution here
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 = nil)
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
+
152
+ profile.values.each do |val|
153
+ ok = val.is_a?(Numeric)
154
+ log(WRN, "Skipping non-numeric value in '#{id}' (#{mth})") unless ok
155
+ next unless ok
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 = nil)
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
+ valid = sched.value.is_a?(Numeric)
193
+ mismatch("'#{id}' value", sched.value, Numeric, mth, ERR, res) unless valid
194
+ res[:min] = sched.value
195
+ res[:max] = sched.value
196
+
197
+ res
198
+ end
199
+
200
+ ##
201
+ # Return min & max values of a schedule (compact).
202
+ #
203
+ # @param sched [OpenStudio::Model::ScheduleCompact] schedule
204
+ #
205
+ # @return [Hash] min: (Float), max: (Float)
206
+ # @return [Hash] min: nil, max: nil (if invalid input)
207
+ def scheduleCompactMinMax(sched = nil)
208
+ # Largely inspired from Andrew Parker's
209
+ # "schedule_compact_annual_min_max_value":
210
+ #
211
+ # github.com/NREL/openstudio-standards/blob/
212
+ # 99cf713750661fe7d2082739f251269c2dfd9140/lib/openstudio-standards/
213
+ # standards/Standards.ScheduleCompact.rb#L8
214
+ mth = "OSut::#{__callee__}"
215
+ cl = OpenStudio::Model::ScheduleCompact
216
+ vals = []
217
+ prev_str = ""
218
+ res = { min: nil, max: nil }
219
+
220
+ return invalid("sched", mth, 1, DBG, res) unless sched.respond_to?(NS)
221
+ id = sched.nameString
222
+ return mismatch(id, sched, cl, mth, DBG, res) unless sched.is_a?(cl)
223
+
224
+ sched.extensibleGroups.each do |eg|
225
+ if prev_str.include?("until")
226
+ vals << eg.getDouble(0).get unless eg.getDouble(0).empty?
227
+ end
228
+
229
+ str = eg.getString(0)
230
+ prev_str = str.get.downcase unless str.empty?
231
+ end
232
+
233
+ return empty("'#{id}' values", mth, ERR, res) if vals.empty?
234
+ ok = vals.min.is_a?(Numeric) && vals.max.is_a?(Numeric)
235
+ log(ERR, "Non-numeric values in '#{id}' (#{mth})") unless ok
236
+ return res unless ok
237
+ res[:min] = vals.min
238
+ res[:max] = vals.max
239
+
240
+ res
241
+ end
242
+
243
+ ##
244
+ # Return min & max values for schedule (interval).
245
+ #
246
+ # @param sched [OpenStudio::Model::ScheduleInterval] schedule
247
+ #
248
+ # @return [Hash] min: (Float), max: (Float)
249
+ # @return [Hash] min: nil, max: nil (if invalid input)
250
+ def scheduleIntervalMinMax(sched = nil)
251
+ mth = "OSut::#{__callee__}"
252
+ cl = OpenStudio::Model::ScheduleInterval
253
+ vals = []
254
+ prev_str = ""
255
+ res = { min: nil, max: nil }
256
+
257
+ return invalid("sched", mth, 1, DBG, res) unless sched.respond_to?(NS)
258
+ id = sched.nameString
259
+ return mismatch(id, sched, cl, mth, DBG, res) unless sched.is_a?(cl)
260
+ vals = sched.timeSeries.values
261
+ ok = vals.min.is_a?(Numeric) && vals.max.is_a?(Numeric)
262
+ log(ERR, "Non-numeric values in '#{id}' (#{mth})") unless ok
263
+ return res unless ok
264
+ res[:min] = vals.min
265
+ res[:max] = vals.max
266
+
267
+ res
268
+ end
269
+
270
+ ##
271
+ # Return max zone heating temperature schedule setpoint [°C] and whether
272
+ # zone has active dual setpoint thermostat.
273
+ #
274
+ # @param zone [OpenStudio::Model::ThermalZone] a thermal zone
275
+ #
276
+ # @return [Hash] spt: (Float), dual: (Bool)
277
+ # @return [Hash] spt: nil, dual: false (if invalid input)
278
+ def maxHeatScheduledSetpoint(zone = nil)
279
+ # Largely inspired from Parker & Marrec's "thermal_zone_heated?" procedure.
280
+ # The solution here is a tad more relaxed to encompass SEMI-HEATED zones as
281
+ # per Canadian NECB criteria (basically any space with at least 10 W/m2 of
282
+ # installed heating equipement, i.e. below freezing in Canada).
283
+ #
284
+ # github.com/NREL/openstudio-standards/blob/
285
+ # 58964222d25783e9da4ae292e375fb0d5c902aa5/lib/openstudio-standards/
286
+ # standards/Standards.ThermalZone.rb#L910
287
+ mth = "OSut::#{__callee__}"
288
+ cl = OpenStudio::Model::ThermalZone
289
+ res = { spt: nil, dual: false }
290
+
291
+ return invalid("zone", mth, 1, DBG, res) unless zone.respond_to?(NS)
292
+ id = zone.nameString
293
+ return mismatch(id, zone, cl, mth, DBG, res) unless zone.is_a?(cl)
294
+
295
+ # Zone radiant heating? Get schedule from radiant system.
296
+ zone.equipment.each do |equip|
297
+ sched = nil
298
+
299
+ unless equip.to_ZoneHVACHighTemperatureRadiant.empty?
300
+ equip = equip.to_ZoneHVACHighTemperatureRadiant.get
301
+
302
+ unless equip.heatingSetpointTemperatureSchedule.empty?
303
+ sched = equip.heatingSetpointTemperatureSchedule.get
304
+ end
305
+ end
306
+
307
+ unless equip.to_ZoneHVACLowTemperatureRadiantElectric.empty?
308
+ equip = equip.to_ZoneHVACLowTemperatureRadiantElectric.get
309
+ sched = equip.heatingSetpointTemperatureSchedule
310
+ end
311
+
312
+ unless equip.to_ZoneHVACLowTempRadiantConstFlow.empty?
313
+ equip = equip.to_ZoneHVACLowTempRadiantConstFlow.get
314
+ coil = equip.heatingCoil
315
+
316
+ unless coil.to_CoilHeatingLowTempRadiantConstFlow.empty?
317
+ coil = coil.to_CoilHeatingLowTempRadiantConstFlow.get
318
+
319
+ unless coil.heatingHighControlTemperatureSchedule.empty?
320
+ sched = c.heatingHighControlTemperatureSchedule.get
321
+ end
322
+ end
323
+ end
324
+
325
+ unless equip.to_ZoneHVACLowTempRadiantVarFlow.empty?
326
+ equip = equip.to_ZoneHVACLowTempRadiantVarFlow.get
327
+ coil = equip.heatingCoil
328
+
329
+ unless coil.to_CoilHeatingLowTempRadiantVarFlow.empty?
330
+ coil = coil.to_CoilHeatingLowTempRadiantVarFlow.get
331
+
332
+ unless coil.heatingControlTemperatureSchedule.empty?
333
+ sched = coil.heatingControlTemperatureSchedule.get
334
+ end
335
+ end
336
+ end
337
+
338
+ next unless sched
339
+
340
+ unless sched.to_ScheduleRuleset.empty?
341
+ sched = sched.to_ScheduleRuleset.get
342
+ max = scheduleRulesetMinMax(sched)[:max]
343
+
344
+ if max
345
+ res[:spt] = max unless res[:spt]
346
+ res[:spt] = max if res[:spt] < max
347
+ end
348
+ end
349
+
350
+ unless sched.to_ScheduleConstant.empty?
351
+ sched = sched.to_ScheduleConstant.get
352
+ max = scheduleConstantMinMax(sched)[:max]
353
+
354
+ if max
355
+ res[:spt] = max unless res[:spt]
356
+ res[:spt] = max if res[:spt] < max
357
+ end
358
+ end
359
+
360
+ unless sched.to_ScheduleCompact.empty?
361
+ sched = sched.to_ScheduleCompact.get
362
+ max = scheduleCompactMinMax(sched)[:max]
363
+
364
+ if max
365
+ res[:spt] = max unless res[:spt]
366
+ res[:spt] = max if res[:spt] < max
367
+ end
368
+ end
369
+ end
370
+
371
+ return res if zone.thermostat.empty?
372
+ tstat = zone.thermostat.get
373
+ res[:spt] = nil
374
+
375
+ unless tstat.to_ThermostatSetpointDualSetpoint.empty? &&
376
+ tstat.to_ZoneControlThermostatStagedDualSetpoint.empty?
377
+
378
+ unless tstat.to_ThermostatSetpointDualSetpoint.empty?
379
+ tstat = tstat.to_ThermostatSetpointDualSetpoint.get
380
+ else
381
+ tstat = tstat.to_ZoneControlThermostatStagedDualSetpoint.get
382
+ end
383
+
384
+ unless tstat.heatingSetpointTemperatureSchedule.empty?
385
+ res[:dual] = true
386
+ sched = tstat.heatingSetpointTemperatureSchedule.get
387
+
388
+ unless sched.to_ScheduleRuleset.empty?
389
+ sched = sched.to_ScheduleRuleset.get
390
+ max = scheduleRulesetMinMax(sched)[:max]
391
+
392
+ if max
393
+ res[:spt] = max unless res[:spt]
394
+ res[:spt] = max if res[:spt] < max
395
+ end
396
+
397
+ dd = sched.winterDesignDaySchedule
398
+
399
+ unless dd.values.empty?
400
+ res[:spt] = dd.values.max unless res[:spt]
401
+ res[:spt] = dd.values.max if res[:spt] < dd.values.max
402
+ end
403
+ end
404
+
405
+ unless sched.to_ScheduleConstant.empty?
406
+ sched = sched.to_ScheduleConstant.get
407
+ max = scheduleConstantMinMax(sched)[:max]
408
+
409
+ if max
410
+ res[:spt] = max unless res[:spt]
411
+ res[:spt] = max if res[:spt] < max
412
+ end
413
+ end
414
+
415
+ unless sched.to_ScheduleCompact.empty?
416
+ sched = sched.to_ScheduleCompact.get
417
+ max = scheduleCompactMinMax(sched)[:max]
418
+
419
+ if max
420
+ res[:spt] = max unless res[:spt]
421
+ res[:spt] = max if res[:spt] < max
422
+ end
423
+ end
424
+
425
+ unless sched.to_ScheduleYear.empty?
426
+ sched = sched.to_ScheduleYear.get
427
+
428
+ sched.getScheduleWeeks.each do |week|
429
+ next if week.winterDesignDaySchedule.empty?
430
+ dd = week.winterDesignDaySchedule.get
431
+ next unless dd.values.empty?
432
+
433
+ res[:spt] = dd.values.max unless res[:spt]
434
+ res[:spt] = dd.values.max if res[:spt] < dd.values.max
435
+ end
436
+ end
437
+ end
438
+ end
439
+
440
+ res
441
+ end
442
+
443
+ ##
444
+ # Validate if model has zones with valid heating temperature setpoints.
445
+ #
446
+ # @param model [OpenStudio::Model::Model] a model
447
+ #
448
+ # @return [Bool] true if valid heating temperature setpoints
449
+ # @return [Bool] false if invalid input
450
+ def heatingTemperatureSetpoints?(model = nil)
451
+ mth = "OSut::#{__callee__}"
452
+ cl = OpenStudio::Model::Model
453
+
454
+ return mismatch("model", model, cl, mth, DBG, false) unless model.is_a?(cl)
455
+
456
+ model.getThermalZones.each do |zone|
457
+ return true if maxHeatScheduledSetpoint(zone)[:spt]
458
+ end
459
+
460
+ false
461
+ end
462
+
463
+ ##
464
+ # Return min zone cooling temperature schedule setpoint [°C] and whether
465
+ # zone has active dual setpoint thermostat.
466
+ #
467
+ # @param zone [OpenStudio::Model::ThermalZone] a thermal zone
468
+ #
469
+ # @return [Hash] spt: (Float), dual: (Bool)
470
+ # @return [Hash] spt: nil, dual: false (if invalid input)
471
+ def minCoolScheduledSetpoint(zone = nil)
472
+ # Largely inspired from Parker & Marrec's "thermal_zone_cooled?" procedure.
473
+ #
474
+ # github.com/NREL/openstudio-standards/blob/
475
+ # 99cf713750661fe7d2082739f251269c2dfd9140/lib/openstudio-standards/
476
+ # standards/Standards.ThermalZone.rb#L1058
477
+ mth = "OSut::#{__callee__}"
478
+ cl = OpenStudio::Model::ThermalZone
479
+ res = { spt: nil, dual: false }
480
+
481
+ return invalid("zone", mth, 1, DBG, res) unless zone.respond_to?(NS)
482
+ id = zone.nameString
483
+ return mismatch(id, zone, cl, mth, DBG, res) unless zone.is_a?(cl)
484
+
485
+ # Zone radiant cooling? Get schedule from radiant system.
486
+ zone.equipment.each do |equip|
487
+ sched = nil
488
+
489
+ unless equip.to_ZoneHVACLowTempRadiantConstFlow.empty?
490
+ equip = equip.to_ZoneHVACLowTempRadiantConstFlow.get
491
+ coil = equip.coolingCoil
492
+
493
+ unless coil.to_CoilCoolingLowTempRadiantConstFlow.empty?
494
+ coil = coil.to_CoilCoolingLowTempRadiantConstFlow.get
495
+
496
+ unless coil.coolingLowControlTemperatureSchedule.empty?
497
+ sched = coil.coolingLowControlTemperatureSchedule.get
498
+ end
499
+ end
500
+ end
501
+
502
+ unless equip.to_ZoneHVACLowTempRadiantVarFlow.empty?
503
+ equip = equip.to_ZoneHVACLowTempRadiantVarFlow.get
504
+ coil = equip.coolingCoil
505
+
506
+ unless coil.to_CoilCoolingLowTempRadiantVarFlow.empty?
507
+ coil = coil.to_CoilCoolingLowTempRadiantVarFlow.get
508
+
509
+ unless coil.coolingControlTemperatureSchedule.empty?
510
+ sched = coil.coolingControlTemperatureSchedule.get
511
+ end
512
+ end
513
+ end
514
+
515
+ next unless sched
516
+
517
+ unless sched.to_ScheduleRuleset.empty?
518
+ sched = sched.to_ScheduleRuleset.get
519
+ min = scheduleRulesetMinMax(sched)[:min]
520
+
521
+ if min
522
+ res[:spt] = min unless res[:spt]
523
+ res[:spt] = min if res[:spt] > min
524
+ end
525
+ end
526
+
527
+ unless sched.to_ScheduleConstant.empty?
528
+ sched = sched.to_ScheduleConstant.get
529
+ min = scheduleConstantMinMax(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_ScheduleCompact.empty?
538
+ sched = sched.to_ScheduleCompact.get
539
+ min = scheduleCompactMinMax(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
+ end
547
+
548
+ return res if zone.thermostat.empty?
549
+ tstat = zone.thermostat.get
550
+ res[:spt] = nil
551
+
552
+ unless tstat.to_ThermostatSetpointDualSetpoint.empty? &&
553
+ tstat.to_ZoneControlThermostatStagedDualSetpoint.empty?
554
+
555
+ unless tstat.to_ThermostatSetpointDualSetpoint.empty?
556
+ tstat = tstat.to_ThermostatSetpointDualSetpoint.get
557
+ else
558
+ tstat = tstat.to_ZoneControlThermostatStagedDualSetpoint.get
559
+ end
560
+
561
+ unless tstat.coolingSetpointTemperatureSchedule.empty?
562
+ res[:dual] = true
563
+ sched = tstat.coolingSetpointTemperatureSchedule.get
564
+
565
+ unless sched.to_ScheduleRuleset.empty?
566
+ sched = sched.to_ScheduleRuleset.get
567
+ min = scheduleRulesetMinMax(sched)[:min]
568
+
569
+ if min
570
+ res[:spt] = min unless res[:spt]
571
+ res[:spt] = min if res[:spt] > min
572
+ end
573
+
574
+ dd = sched.summerDesignDaySchedule
575
+
576
+ unless dd.values.empty?
577
+ res[:spt] = dd.values.min unless res[:spt]
578
+ res[:spt] = dd.values.min if res[:spt] > dd.values.min
579
+ end
580
+ end
581
+
582
+ unless sched.to_ScheduleConstant.empty?
583
+ sched = sched.to_ScheduleConstant.get
584
+ min = scheduleConstantMinMax(sched)[:min]
585
+
586
+ if min
587
+ res[:spt] = min unless res[:spt]
588
+ res[:spt] = min if res[:spt] > min
589
+ end
590
+ end
591
+
592
+ unless sched.to_ScheduleCompact.empty?
593
+ sched = sched.to_ScheduleCompact.get
594
+ min = scheduleCompactMinMax(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_ScheduleYear.empty?
603
+ sched = sched.to_ScheduleYear.get
604
+
605
+ sched.getScheduleWeeks.each do |week|
606
+ next if week.summerDesignDaySchedule.empty?
607
+ dd = week.summerDesignDaySchedule.get
608
+ next unless dd.values.empty?
609
+
610
+ res[:spt] = dd.values.min unless res[:spt]
611
+ res[:spt] = dd.values.min if res[:spt] > dd.values.min
612
+ end
613
+ end
614
+ end
615
+ end
616
+
617
+ res
618
+ end
619
+
620
+ ##
621
+ # Validate if model has zones with valid cooling temperature setpoints.
622
+ #
623
+ # @param model [OpenStudio::Model::Model] a model
624
+ #
625
+ # @return [Bool] true if valid cooling temperature setpoints
626
+ # @return [Bool] false if invalid input
627
+ def coolingTemperatureSetpoints?(model = nil)
628
+ mth = "OSut::#{__callee__}"
629
+ cl = OpenStudio::Model::Model
630
+
631
+ return mismatch("model", model, cl, mth, DBG, false) unless model.is_a?(cl)
632
+
633
+ model.getThermalZones.each do |zone|
634
+ return true if minCoolScheduledSetpoint(zone)[:spt]
635
+ end
636
+
637
+ false
638
+ end
639
+
640
+ ##
641
+ # Validate if model has zones with HVAC air loops.
642
+ #
643
+ # @param model [OpenStudio::Model::Model] a model
644
+ #
645
+ # @return [Bool] true if model has one or more HVAC air loops
646
+ # @return [Bool] false if invalid input
647
+ def airLoopsHVAC?(model = nil)
648
+ mth = "OSut::#{__callee__}"
649
+ cl = OpenStudio::Model::Model
650
+
651
+ return mismatch("model", model, cl, mth, DBG, false) unless model.is_a?(cl)
652
+
653
+ model.getThermalZones.each do |zone|
654
+ next if zone.canBePlenum
655
+ return true unless zone.airLoopHVACs.empty?
656
+ return true if zone.isPlenum
657
+ end
658
+
659
+ false
660
+ end
661
+
662
+ ##
663
+ # Validate whether space should be processed as a plenum.
664
+ #
665
+ # @param space [OpenStudio::Model::Space] a space
666
+ # @param loops [Bool] true if model has airLoopHVAC object(s)
667
+ # @param setpoints [Bool] true if model has valid temperature setpoints
668
+ #
669
+ # @return [Bool] true if should be tagged as plenum
670
+ # @return [Bool] false if invalid input
671
+ def plenum?(space = nil, loops = nil, setpoints = nil)
672
+ # Largely inspired from NREL's "space_plenum?" procedure:
673
+ #
674
+ # github.com/NREL/openstudio-standards/blob/
675
+ # 58964222d25783e9da4ae292e375fb0d5c902aa5/lib/openstudio-standards/
676
+ # standards/Standards.Space.rb#L1384
677
+ #
678
+ # A space may be tagged as a plenum if:
679
+ #
680
+ # CASE A: its zone's "isPlenum" == true (SDK method) for a fully-developed
681
+ # OpenStudio model (complete with HVAC air loops); OR
682
+ #
683
+ # CASE B: (IN ABSENCE OF HVAC AIRLOOPS) if it's excluded from a building's
684
+ # total floor area yet linked to a zone holding an 'inactive'
685
+ # thermostat, i.e. can't extract valid setpoints; OR
686
+ #
687
+ # CASE C: (IN ABSENCE OF HVAC AIRLOOPS & VALID SETPOINTS) it has "plenum"
688
+ # (case insensitive) as a spacetype (or as a spacetype's
689
+ # 'standards spacetype').
690
+ mth = "OSut::#{__callee__}"
691
+ cl = OpenStudio::Model::Space
692
+
693
+ return invalid("space", mth, 1, DBG, false) unless space.respond_to?(NS)
694
+ id = space.nameString
695
+ return mismatch(id, space, cl, mth, DBG, false) unless space.is_a?(cl)
696
+ valid = loops == true || loops == false
697
+ return invalid("loops", mth, 2, DBG, false) unless valid
698
+ valid = setpoints == true || setpoints == false
699
+ return invalid("setpoints", mth, 3, DBG, false) unless valid
700
+
701
+ unless space.thermalZone.empty?
702
+ zone = space.thermalZone.get
703
+ return zone.isPlenum if loops # A
704
+
705
+ if setpoints
706
+ heat = maxHeatScheduledSetpoint(zone)
707
+ cool = minCoolScheduledSetpoint(zone)
708
+ return false if heat[:spt] || cool[:spt] # directly conditioned
709
+ return heat[:dual] || cool[:dual] unless space.partofTotalFloorArea # B
710
+ return false
711
+ end
712
+ end
713
+
714
+ unless space.spaceType.empty?
715
+ type = space.spaceType.get
716
+ return type.nameString.downcase == "plenum" # C
717
+
718
+ unless type.standardsSpaceType.empty?
719
+ type = type.standardsSpaceType.get
720
+ return type.downcase == "plenum" # C
721
+ end
722
+ end
723
+
724
+ false
725
+ end
726
+
727
+ ##
728
+ # Generate an HVAC availability schedule.
729
+ #
730
+ # @param model [OpenStudio::Model::Model] a model
731
+ # @param avl [String] seasonal availability choice (optional, default "ON")
732
+ #
733
+ # @return [OpenStudio::Model::Schedule] HVAC availability sched
734
+ # @return [NilClass] if invalid input
735
+ def availabilitySchedule(model = nil, avl = "")
736
+ mth = "OSut::#{__callee__}"
737
+ cl = OpenStudio::Model::Model
738
+ limits = nil
739
+
740
+ return mismatch("model", model, cl, mth) unless model.is_a?(cl)
741
+ return invalid("availability", avl, 2, mth) unless avl.respond_to?(:to_s)
742
+
743
+ # Either fetch availability ScheduleTypeLimits object, or create one.
744
+ model.getScheduleTypeLimitss.each do |l|
745
+ break if limits
746
+ next if l.lowerLimitValue.empty?
747
+ next if l.upperLimitValue.empty?
748
+ next if l.numericType.empty?
749
+ next unless l.lowerLimitValue.get.to_i == 0
750
+ next unless l.upperLimitValue.get.to_i == 1
751
+ next unless l.numericType.get.downcase == "discrete"
752
+ next unless l.unitType.downcase == "availability"
753
+ next unless l.nameString.downcase == "hvac operation scheduletypelimits"
754
+ limits = l
755
+ end
756
+
757
+ unless limits
758
+ limits = OpenStudio::Model::ScheduleTypeLimits.new(model)
759
+ limits.setName("HVAC Operation ScheduleTypeLimits")
760
+ limits.setLowerLimitValue(0)
761
+ limits.setUpperLimitValue(1)
762
+ limits.setNumericType("Discrete")
763
+ limits.setUnitType("Availability")
764
+ end
765
+
766
+ time = OpenStudio::Time.new(0,24)
767
+ secs = time.totalSeconds
768
+ on = OpenStudio::Model::ScheduleDay.new(model, 1)
769
+ off = OpenStudio::Model::ScheduleDay.new(model, 0)
770
+
771
+ # Seasonal availability start/end dates.
772
+ year = model.yearDescription
773
+ return empty("yearDescription", mth, ERR) if year.empty?
774
+ year = year.get
775
+ may01 = year.makeDate(OpenStudio::MonthOfYear.new("May"), 1)
776
+ oct31 = year.makeDate(OpenStudio::MonthOfYear.new("Oct"), 31)
777
+
778
+ case avl.to_s.downcase
779
+ when "winter" # available from November 1 to April 30 (6 months)
780
+ val = 1
781
+ sch = off
782
+ nom = "WINTER Availability SchedRuleset"
783
+ dft = "WINTER Availability dftDaySched"
784
+ tag = "May-Oct WINTER Availability SchedRule"
785
+ day = "May-Oct WINTER SchedRule Day"
786
+ when "summer" # available from May 1 to October 31 (6 months)
787
+ val = 0
788
+ sch = on
789
+ nom = "SUMMER Availability SchedRuleset"
790
+ dft = "SUMMER Availability dftDaySched"
791
+ tag = "May-Oct SUMMER Availability SchedRule"
792
+ day = "May-Oct SUMMER SchedRule Day"
793
+ when "off" # never available
794
+ val = 0
795
+ sch = on
796
+ nom = "OFF Availability SchedRuleset"
797
+ dft = "OFF Availability dftDaySched"
798
+ tag = ""
799
+ day = ""
800
+ else # always available
801
+ val = 1
802
+ sch = on
803
+ nom = "ON Availability SchedRuleset"
804
+ dft = "ON Availability dftDaySched"
805
+ tag = ""
806
+ day = ""
807
+ end
808
+
809
+ # Fetch existing schedule.
810
+ ok = true
811
+ schedule = model.getScheduleByName(nom)
812
+
813
+ unless schedule.empty?
814
+ schedule = schedule.get.to_ScheduleRuleset
815
+
816
+ unless schedule.empty?
817
+ schedule = schedule.get
818
+ default = schedule.defaultDaySchedule
819
+ ok = ok && default.nameString == dft
820
+ ok = ok && default.times.size == 1
821
+ ok = ok && default.values.size == 1
822
+ ok = ok && default.times.first == time
823
+ ok = ok && default.values.first == val
824
+ rules = schedule.scheduleRules
825
+ ok = ok && (rules.size == 0 || rules.size == 1)
826
+
827
+ if rules.size == 1
828
+ rule = rules.first
829
+ ok = ok && rule.nameString == tag
830
+ ok = ok && !rule.startDate.empty?
831
+ ok = ok && !rule.endDate.empty?
832
+ ok = ok && rule.startDate.get == may01
833
+ ok = ok && rule.endDate.get == oct31
834
+ ok = ok && rule.applyAllDays
835
+
836
+ d = rule.daySchedule
837
+ ok = ok && d.nameString == day
838
+ ok = ok && d.times.size == 1
839
+ ok = ok && d.values.size == 1
840
+ ok = ok && d.times.first.totalSeconds == secs
841
+ ok = ok && d.values.first.to_i != val
842
+ end
843
+
844
+ return schedule if ok
845
+ end
846
+ end
847
+
848
+ schedule = OpenStudio::Model::ScheduleRuleset.new(model)
849
+ schedule.setName(nom)
850
+ ok = schedule.setScheduleTypeLimits(limits)
851
+ log(ERR, "'#{nom}': Can't set schedule type limits (#{mth})") unless ok
852
+ return nil unless ok
853
+ ok = schedule.defaultDaySchedule.addValue(time, val)
854
+ log(ERR, "'#{nom}': Can't set default day schedule (#{mth})") unless ok
855
+ return nil unless ok
856
+ schedule.defaultDaySchedule.setName(dft)
857
+
858
+ unless tag.empty?
859
+ rule = OpenStudio::Model::ScheduleRule.new(schedule, sch)
860
+ rule.setName(tag)
861
+ ok = rule.setStartDate(may01)
862
+ log(ERR, "'#{tag}': Can't set start date (#{mth})") unless ok
863
+ return nil unless ok
864
+ ok = rule.setEndDate(oct31)
865
+ log(ERR, "'#{tag}': Can't set end date (#{mth})") unless ok
866
+ return nil unless ok
867
+ ok = rule.setApplyAllDays(true)
868
+ log(ERR, "'#{tag}': Can't apply to all days (#{mth})") unless ok
869
+ return nil unless ok
870
+ rule.daySchedule.setName(day)
871
+ end
872
+
873
+ schedule
874
+ end
875
+
876
+ ##
877
+ # Validate if default construction set holds a base ground construction.
878
+ #
879
+ # @param set [OpenStudio::Model::DefaultConstructionSet] a default set
880
+ # @param bse [OpensStudio::Model::ConstructionBase] a construction base
881
+ # @param gr [Bool] true if ground-facing surface
882
+ # @param ex [Bool] true if exterior-facing surface
883
+ # @param typ [String] a surface type
884
+ #
885
+ # @return [Bool] true if default construction set holds construction
886
+ # @return [Bool] false if invalid input
887
+ def holdsConstruction?(set = nil, bse = nil, gr = false, ex = false, typ = "")
888
+ mth = "OSut::#{__callee__}"
889
+ cl1 = OpenStudio::Model::DefaultConstructionSet
890
+ cl2 = OpenStudio::Model::ConstructionBase
891
+
892
+ return invalid("set", mth, 1, DBG, false) unless set.respond_to?(NS)
893
+ id = set.nameString
894
+ return mismatch(id, set, cl1, mth, DBG, false) unless set.is_a?(cl1)
895
+ return invalid("base", mth, 2, DBG, false) unless bse.respond_to?(NS)
896
+ id = bse.nameString
897
+ return mismatch(id, bse, cl2, mth, DBG, false) unless bse.is_a?(cl2)
898
+ valid = gr == true || gr == false
899
+ return invalid("ground", mth, 3, DBG, false) unless valid
900
+ valid = ex == true || ex == false
901
+ return invalid("exterior", mth, 4, DBG, false) unless valid
902
+ valid = typ.respond_to?(:to_s)
903
+ return invalid("surface typ", mth, 4, DBG, false) unless valid
904
+ type = typ.to_s.downcase
905
+ valid = type == "floor" || type == "wall" || type == "roofceiling"
906
+ return invalid("surface type", mth, 5, DBG, false) unless valid
907
+
908
+ constructions = nil
909
+
910
+ if gr
911
+ unless set.defaultGroundContactSurfaceConstructions.empty?
912
+ constructions = set.defaultGroundContactSurfaceConstructions.get
913
+ end
914
+ elsif ex
915
+ unless set.defaultExteriorSurfaceConstructions.empty?
916
+ constructions = set.defaultExteriorSurfaceConstructions.get
917
+ end
918
+ else
919
+ unless set.defaultInteriorSurfaceConstructions.empty?
920
+ constructions = set.defaultInteriorSurfaceConstructions.get
921
+ end
922
+ end
923
+
924
+ return false unless constructions
925
+
926
+ case type
927
+ when "roofceiling"
928
+ unless constructions.roofCeilingConstruction.empty?
929
+ construction = constructions.roofCeilingConstruction.get
930
+ return true if construction == bse
931
+ end
932
+ when "floor"
933
+ unless constructions.floorConstruction.empty?
934
+ construction = constructions.floorConstruction.get
935
+ return true if construction == bse
936
+ end
937
+ else
938
+ unless constructions.wallConstruction.empty?
939
+ construction = constructions.wallConstruction.get
940
+ return true if construction == bse
941
+ end
942
+ end
943
+
944
+ false
945
+ end
946
+
947
+ ##
948
+ # Return a surface's default construction set.
949
+ #
950
+ # @param model [OpenStudio::Model::Model] a model
951
+ # @param s [OpenStudio::Model::Surface] a surface
952
+ #
953
+ # @return [OpenStudio::Model::DefaultConstructionSet] default set
954
+ # @return [NilClass] if invalid input
955
+ def defaultConstructionSet(model = nil, s = nil)
956
+ mth = "OSut::#{__callee__}"
957
+ cl1 = OpenStudio::Model::Model
958
+ cl2 = OpenStudio::Model::Surface
959
+
960
+ return mismatch("model", model, cl1, mth) unless model.is_a?(cl1)
961
+ return invalid("s", mth, 2) unless s.respond_to?(NS)
962
+ id = s.nameString
963
+ return mismatch(id, s, cl2, mth) unless s.is_a?(cl2)
964
+
965
+ ok = s.isConstructionDefaulted
966
+ log(ERR, "'#{id}' construction not defaulted (#{mth})") unless ok
967
+ return nil unless ok
968
+ return empty("'#{id}' construction", mth, ERR) if s.construction.empty?
969
+ base = s.construction.get
970
+ return empty("'#{id}' space", mth, ERR) if s.space.empty?
971
+ space = s.space.get
972
+ type = s.surfaceType
973
+ ground = false
974
+ exterior = false
975
+
976
+ if s.isGroundSurface
977
+ ground = true
978
+ elsif s.outsideBoundaryCondition.downcase == "outdoors"
979
+ exterior = true
980
+ end
981
+
982
+ unless space.defaultConstructionSet.empty?
983
+ set = space.defaultConstructionSet.get
984
+ return set if holdsConstruction?(set, base, ground, exterior, type)
985
+ end
986
+
987
+ unless space.spaceType.empty?
988
+ spacetype = space.spaceType.get
989
+
990
+ unless spacetype.defaultConstructionSet.empty?
991
+ set = spacetype.defaultConstructionSet.get
992
+ return set if holdsConstruction?(set, base, ground, exterior, type)
993
+ end
994
+ end
995
+
996
+ unless space.buildingStory.empty?
997
+ story = space.buildingStory.get
998
+
999
+ unless story.defaultConstructionSet.empty?
1000
+ set = story.defaultConstructionSet.get
1001
+ return set if holdsConstruction?(set, base, ground, exterior, type)
1002
+ end
1003
+ end
1004
+
1005
+ building = model.getBuilding
1006
+
1007
+ unless building.defaultConstructionSet.empty?
1008
+ set = building.defaultConstructionSet.get
1009
+ return set if holdsConstruction?(set, base, ground, exterior, type)
1010
+ end
1011
+
1012
+ nil
1013
+ end
1014
+
1015
+ ##
1016
+ # Validate if every material in a layered construction is standard & opaque.
1017
+ #
1018
+ # @param lc [OpenStudio::LayeredConstruction] a layered construction
1019
+ #
1020
+ # @return [Bool] true if all layers are valid
1021
+ # @return [Bool] false if invalid input
1022
+ def standardOpaqueLayers?(lc = nil)
1023
+ mth = "OSut::#{__callee__}"
1024
+ cl = OpenStudio::Model::LayeredConstruction
1025
+
1026
+ return invalid("lc", mth, 1, DBG, false) unless lc.respond_to?(NS)
1027
+ return mismatch(lc.nameString, lc, cl, mth, DBG, false) unless lc.is_a?(cl)
1028
+
1029
+ lc.layers.each { |m| return false if m.to_StandardOpaqueMaterial.empty? }
1030
+
1031
+ true
1032
+ end
1033
+
1034
+ ##
1035
+ # Total (standard opaque) layered construction thickness (in m).
1036
+ #
1037
+ # @param lc [OpenStudio::LayeredConstruction] a layered construction
1038
+ #
1039
+ # @return [Float] total layered construction thickness
1040
+ # @return [Float] 0 if invalid input
1041
+ def thickness(lc = nil)
1042
+ mth = "OSut::#{__callee__}"
1043
+ cl = OpenStudio::Model::LayeredConstruction
1044
+
1045
+ return invalid("lc", mth, 1, DBG, 0.0) unless lc.respond_to?(NS)
1046
+ id = lc.nameString
1047
+ return mismatch(id, lc, cl, mth, DBG, 0.0) unless lc.is_a?(cl)
1048
+
1049
+ ok = standardOpaqueLayers?(lc)
1050
+ log(ERR, "'#{id}' holds non-StandardOpaqueMaterial(s) (#{mth})") unless ok
1051
+ return 0.0 unless ok
1052
+ thickness = 0.0
1053
+ lc.layers.each { |m| thickness += m.thickness }
1054
+
1055
+ thickness
1056
+ end
1057
+
1058
+ ##
1059
+ # Return total air film resistance for fenestration.
1060
+ #
1061
+ # @param usi [Float] a fenestrated construction's U-factor (W/m2•K)
1062
+ #
1063
+ # @return [Float] total air film resistance in m2•K/W (0.1216 if errors)
1064
+ def glazingAirFilmRSi(usi = 5.85)
1065
+ # The sum of thermal resistances of calculated exterior and interior film
1066
+ # coefficients under standard winter conditions are taken from:
1067
+ #
1068
+ # https://bigladdersoftware.com/epx/docs/9-6/engineering-reference/
1069
+ # window-calculation-module.html#simple-window-model
1070
+ #
1071
+ # These remain acceptable approximations for flat windows, yet likely
1072
+ # unsuitable for subsurfaces with curved or projecting shapes like domed
1073
+ # skylights. The solution here is considered an adequate fix for reporting,
1074
+ # awaiting eventual OpenStudio (and EnergyPlus) upgrades to report NFRC 100
1075
+ # (or ISO) air film resistances under standard winter conditions.
1076
+ #
1077
+ # For U-factors above 8.0 W/m2•K (or invalid input), the function returns
1078
+ # 0.1216 m2•K/W, which corresponds to a construction with a single glass
1079
+ # layer thickness of 2mm & k = ~0.6 W/m.K.
1080
+ #
1081
+ # The EnergyPlus Engineering calculations were designed for vertical windows
1082
+ # - not horizontal, slanted or domed surfaces - use with caution.
1083
+ mth = "OSut::#{__callee__}"
1084
+ cl = Numeric
1085
+
1086
+ return mismatch("usi", usi, cl, mth, DBG, 0.1216) unless usi.is_a?(cl)
1087
+ return invalid("usi", mth, 1, WRN, 0.1216) if usi > 8.0
1088
+ return negative("usi", mth, WRN, 0.1216) if usi < 0
1089
+ return zero("usi", mth, WRN, 0.1216) if usi.abs < TOL
1090
+
1091
+ rsi = 1 / (0.025342 * usi + 29.163853) # exterior film, next interior film
1092
+
1093
+ return rsi + 1 / (0.359073 * Math.log(usi) + 6.949915) if usi < 5.85
1094
+ return rsi + 1 / (1.788041 * usi - 2.886625)
1095
+ end
1096
+
1097
+ ##
1098
+ # Return a construction's 'standard calc' thermal resistance (with air films).
1099
+ #
1100
+ # @param lc [OpenStudio::Model::LayeredConstruction] a layered construction
1101
+ # @param film [Float] thermal resistance of surface air films (m2•K/W)
1102
+ # @param t [Float] gas temperature (°C) (optional)
1103
+ #
1104
+ # @return [Float] calculated RSi at standard conditions (0 if error)
1105
+ def rsi(lc = nil, film = 0.0, t = 0.0)
1106
+ # This is adapted from BTAP's Material Module's "get_conductance" (P. Lopez)
1107
+ #
1108
+ # https://github.com/NREL/OpenStudio-Prototype-Buildings/blob/
1109
+ # c3d5021d8b7aef43e560544699fb5c559e6b721d/lib/btap/measures/
1110
+ # btap_equest_converter/envelope.rb#L122
1111
+ mth = "OSut::#{__callee__}"
1112
+ cl1 = OpenStudio::Model::LayeredConstruction
1113
+ cl2 = Numeric
1114
+
1115
+ return invalid("lc", mth, 1, DBG, 0.0) unless lc.respond_to?(NS)
1116
+ id = lc.nameString
1117
+ return mismatch(id, lc, cl1, mth, DBG, 0.0) unless lc.is_a?(cl1)
1118
+ return mismatch("film", film, cl2, mth, DBG, 0.0) unless film.is_a?(cl2)
1119
+ return mismatch("temp K", t, cl2, mth, DBG, 0.0) unless t.is_a?(cl2)
1120
+ t += 273.0 # °C to K
1121
+ return negative("temp K", mth, DBG, 0.0) if t < 0
1122
+ return negative("film", mth, DBG, 0.0) if film < 0
1123
+
1124
+ rsi = film
1125
+
1126
+ lc.layers.each do |m|
1127
+ # Fenestration materials first (ignoring shades, screens, etc.)
1128
+ empty = m.to_SimpleGlazing.empty?
1129
+ return 1 / m.to_SimpleGlazing.get.uFactor unless empty
1130
+ empty = m.to_StandardGlazing.empty?
1131
+ rsi += m.to_StandardGlazing.get.thermalResistance unless empty
1132
+ empty = m.to_RefractionExtinctionGlazing.empty?
1133
+ rsi += m.to_RefractionExtinctionGlazing.get.thermalResistance unless empty
1134
+ empty = m.to_Gas.empty?
1135
+ rsi += m.to_Gas.get.getThermalResistance(t) unless empty
1136
+ empty = m.to_GasMixture.empty?
1137
+ rsi += m.to_GasMixture.get.getThermalResistance(t) unless empty
1138
+
1139
+ # Opaque materials next.
1140
+ empty = m.to_StandardOpaqueMaterial.empty?
1141
+ rsi += m.to_StandardOpaqueMaterial.get.thermalResistance unless empty
1142
+ empty = m.to_MasslessOpaqueMaterial.empty?
1143
+ rsi += m.to_MasslessOpaqueMaterial.get.thermalResistance unless empty
1144
+ empty = m.to_RoofVegetation.empty?
1145
+ rsi += m.to_RoofVegetation.get.thermalResistance unless empty
1146
+ empty = m.to_AirGap.empty?
1147
+ rsi += m.to_AirGap.get.thermalResistance unless empty
1148
+ end
1149
+
1150
+ rsi
1151
+ end
1152
+
1153
+ ##
1154
+ # Identify a layered construction's (opaque) insulating layer. The method
1155
+ # returns a 3-keyed hash ... :index (insulating layer index within layered
1156
+ # construction), :type (standard: or massless: material type), and
1157
+ # :r (material thermal resistance in m2•K/W).
1158
+ #
1159
+ # @param lc [OpenStudio::Model::LayeredConstruction] a layered construction
1160
+ #
1161
+ # @return [Hash] index: (Integer), type: (:standard or :massless), r: (Float)
1162
+ # @return [Hash] index: nil, type: nil, r: 0 (if invalid input)
1163
+ def insulatingLayer(lc = nil)
1164
+ mth = "OSut::#{__callee__}"
1165
+ cl = OpenStudio::Model::LayeredConstruction
1166
+ res = { index: nil, type: nil, r: 0.0 }
1167
+ i = 0 # iterator
1168
+
1169
+ return invalid("lc", mth, 1, DBG, res) unless lc.respond_to?(NS)
1170
+ id = lc.nameString
1171
+ return mismatch(id, lc, cl1, mth, DBG, res) unless lc.is_a?(cl)
1172
+
1173
+ lc.layers.each do |m|
1174
+ unless m.to_MasslessOpaqueMaterial.empty?
1175
+ m = m.to_MasslessOpaqueMaterial.get
1176
+
1177
+ if m.thermalResistance < 0.001 || m.thermalResistance < res[:r]
1178
+ i += 1
1179
+ next
1180
+ else
1181
+ res[:r ] = m.thermalResistance
1182
+ res[:index] = i
1183
+ res[:type ] = :massless
1184
+ end
1185
+ end
1186
+
1187
+ unless m.to_StandardOpaqueMaterial.empty?
1188
+ m = m.to_StandardOpaqueMaterial.get
1189
+ k = m.thermalConductivity
1190
+ d = m.thickness
1191
+
1192
+ if d < 0.003 || k > 3.0 || d / k < res[:r]
1193
+ i += 1
1194
+ next
1195
+ else
1196
+ res[:r ] = d / k
1197
+ res[:index] = i
1198
+ res[:type ] = :standard
1199
+ end
1200
+ end
1201
+
1202
+ i += 1
1203
+ end
1204
+
1205
+ res
1206
+ end
1207
+
1208
+ ##
1209
+ # Return OpenStudio site/space transformation & rotation angle [0,2PI) rads.
1210
+ #
1211
+ # @param model [OpenStudio::Model::Model] a model
1212
+ # @param group [OpenStudio::Model::PlanarSurfaceGroup] a group
1213
+ #
1214
+ # @return [Hash] t: (OpenStudio::Transformation), r: Float
1215
+ # @return [Hash] t: nil, r: nil (if invalid input)
1216
+ def transforms(model = nil, group = nil)
1217
+ mth = "OSut::#{__callee__}"
1218
+ cl1 = OpenStudio::Model::Model
1219
+ cl2 = OpenStudio::Model::PlanarSurfaceGroup
1220
+ res = { t: nil, r: nil }
1221
+
1222
+ return mismatch("model", model, cl1, mth, DBG, res) unless model.is_a?(cl1)
1223
+ return invalid("group", mth, 2, DBG, res) unless group.respond_to?(NS)
1224
+ id = group.nameString
1225
+ return mismatch(id, group, cl2, mth, DBG, res) unless group.is_a?(cl2)
1226
+
1227
+ res[:t] = group.siteTransformation
1228
+ res[:r] = group.directionofRelativeNorth + model.getBuilding.northAxis
1229
+
1230
+ res
1231
+ end
1232
+
1233
+ ##
1234
+ # Return a scalar product of an OpenStudio Vector3d.
1235
+ #
1236
+ # @param v [OpenStudio::Vector3d] a vector
1237
+ # @param m [Float] a scalar
1238
+ #
1239
+ # @return [OpenStudio::Vector3d] modified vector
1240
+ # @return [OpenStudio::Vector3d] provided (or empty) vector if invalid input
1241
+ def scalar(v = OpenStudio::Vector3d.new(0,0,0), m = 0)
1242
+ mth = "OSut::#{__callee__}"
1243
+ cl1 = OpenStudio::Vector3d
1244
+ cl2 = Numeric
1245
+
1246
+ return mismatch("vector", v, cl1, mth, DBG, v) unless v.is_a?(cl1)
1247
+ return mismatch("x", v.x, cl2, mth, DBG, v) unless v.x.respond_to?(:to_f)
1248
+ return mismatch("y", v.y, cl2, mth, DBG, v) unless v.y.respond_to?(:to_f)
1249
+ return mismatch("z", v.z, cl2, mth, DBG, v) unless v.z.respond_to?(:to_f)
1250
+ return mismatch("m", m, cl2, mth, DBG, v) unless m.respond_to?(:to_f)
1251
+
1252
+ OpenStudio::Vector3d.new(m * v.x, m * v.y, m * v.z)
1253
+ end
1254
+
1255
+ ##
1256
+ # Flatten OpenStudio 3D points vs Z-axis (Z=0).
1257
+ #
1258
+ # @param pts [Array] an OpenStudio Point3D array/vector
1259
+ #
1260
+ # @return [Array] flattened OpenStudio 3D points
1261
+ def flatZ(pts = nil)
1262
+ mth = "OSut::#{__callee__}"
1263
+ cl1 = OpenStudio::Point3dVector
1264
+ cl2 = OpenStudio::Point3d
1265
+ v = OpenStudio::Point3dVector.new
1266
+
1267
+ valid = pts.is_a?(cl1) || pts.is_a?(Array)
1268
+ return mismatch("points", pts, cl1, mth, DBG, v) unless valid
1269
+ pts.each { |pt| mismatch("pt", pt, cl2, mth, ERR, v) unless pt.is_a?(cl2) }
1270
+ pts.each { |pt| v << OpenStudio::Point3d.new(pt.x, pt.y, 0) }
1271
+
1272
+ v
1273
+ end
1274
+
1275
+ ##
1276
+ # Validate whether 1st OpenStudio convex polygon fits in 2nd convex polygon.
1277
+ #
1278
+ # @param p1 [OpenStudio::Point3dVector] or Point3D array of polygon #1
1279
+ # @param p2 [OpenStudio::Point3dVector] or Point3D array of polygon #2
1280
+ # @param id1 [String] polygon #1 identifier (optional)
1281
+ # @param id2 [String] polygon #2 identifier (optional)
1282
+ #
1283
+ # @return [Bool] true if 1st polygon fits entirely within the 2nd polygon
1284
+ # @return [Bool] false if invalid input
1285
+ def fits?(p1 = nil, p2 = nil, id1 = "", id2 = "")
1286
+ mth = "OSut::#{__callee__}"
1287
+ cl1 = OpenStudio::Point3dVector
1288
+ cl2 = OpenStudio::Point3d
1289
+ a = false
1290
+
1291
+ return invalid("id1", mth, 3, DBG, a) unless id1.respond_to?(:to_s)
1292
+ return invalid("id2", mth, 4, DBG, a) unless id2.respond_to?(:to_s)
1293
+
1294
+ i1 = id1.to_s
1295
+ i2 = id2.to_s
1296
+ i1 = "poly1" if i1.empty?
1297
+ i2 = "poly2" if i2.empty?
1298
+
1299
+ valid1 = p1.is_a?(cl1) || p1.is_a?(Array)
1300
+ valid2 = p2.is_a?(cl1) || p2.is_a?(Array)
1301
+
1302
+ return mismatch(i1, p1, cl1, mth, DBG, a) unless valid1
1303
+ return mismatch(i2, p2, cl1, mth, DBG, a) unless valid2
1304
+ return empty(i1, mth, ERR, a) if p1.empty?
1305
+ return empty(i2, mth, ERR, a) if p2.empty?
1306
+
1307
+ p1.each { |v| return mismatch(i1, v, cl2, mth, ERR, a) unless v.is_a?(cl2) }
1308
+ p2.each { |v| return mismatch(i2, v, cl2, mth, ERR, a) unless v.is_a?(cl2) }
1309
+
1310
+ # XY-plane transformation matrix ... needs to be clockwise for boost.
1311
+ ft = OpenStudio::Transformation.alignFace(p1)
1312
+ ft_p1 = flatZ( (ft.inverse * p1) )
1313
+ return false if ft_p1.empty?
1314
+ cw = OpenStudio.pointInPolygon(ft_p1.first, ft_p1, TOL)
1315
+ ft_p1 = flatZ( (ft.inverse * p1).reverse ) unless cw
1316
+ ft_p2 = flatZ( (ft.inverse * p2).reverse ) unless cw
1317
+ ft_p2 = flatZ( (ft.inverse * p2) ) if cw
1318
+ return false if ft_p2.empty?
1319
+ area1 = OpenStudio.getArea(ft_p1)
1320
+ area2 = OpenStudio.getArea(ft_p2)
1321
+ return empty("#{i1} area", mth, ERR, a) if area1.empty?
1322
+ return empty("#{i2} area", mth, ERR, a) if area2.empty?
1323
+ area1 = area1.get
1324
+ area2 = area2.get
1325
+ union = OpenStudio.join(ft_p1, ft_p2, TOL2)
1326
+ return false if union.empty?
1327
+ union = union.get
1328
+ area = OpenStudio.getArea(union)
1329
+ return empty("#{i1}:#{i2} union area", mth, ERR, a) if area.empty?
1330
+ area = area.get
1331
+ return false if area < TOL
1332
+ return true if (area - area2).abs < TOL
1333
+ return false if (area - area2).abs > TOL
1334
+
1335
+ true
1336
+ end
1337
+
1338
+ ##
1339
+ # Validate whether an OpenStudio polygon overlaps another.
1340
+ #
1341
+ # @param p1 [OpenStudio::Point3dVector] or Point3D array of polygon #1
1342
+ # @param p2 [OpenStudio::Point3dVector] or Point3D array of polygon #2
1343
+ # @param id1 [String] polygon #1 identifier (optional)
1344
+ # @param id2 [String] polygon #2 identifier (optional)
1345
+ #
1346
+ # @return Returns true if polygons overlaps (or either fits into the other)
1347
+ # @return [Bool] false if invalid input
1348
+ def overlaps?(p1 = nil, p2 = nil, id1 = "", id2 = "")
1349
+ mth = "OSut::#{__callee__}"
1350
+ cl1 = OpenStudio::Point3dVector
1351
+ cl2 = OpenStudio::Point3d
1352
+ a = false
1353
+
1354
+ return invalid("id1", mth, 3, DBG, a) unless id1.respond_to?(:to_s)
1355
+ return invalid("id2", mth, 4, DBG, a) unless id2.respond_to?(:to_s)
1356
+
1357
+ i1 = id1.to_s
1358
+ i2 = id2.to_s
1359
+ i1 = "poly1" if i1.empty?
1360
+ i2 = "poly2" if i2.empty?
1361
+
1362
+ valid1 = p1.is_a?(cl1) || p1.is_a?(Array)
1363
+ valid2 = p2.is_a?(cl1) || p2.is_a?(Array)
1364
+
1365
+ return mismatch(i1, p1, cl1, mth, DBG, a) unless valid1
1366
+ return mismatch(i2, p2, cl1, mth, DBG, a) unless valid2
1367
+ return empty(i1, mth, ERR, a) if p1.empty?
1368
+ return empty(i2, mth, ERR, a) if p2.empty?
1369
+
1370
+ p1.each { |v| return mismatch(i1, v, cl2, mth, ERR, a) unless v.is_a?(cl2) }
1371
+ p2.each { |v| return mismatch(i2, v, cl2, mth, ERR, a) unless v.is_a?(cl2) }
1372
+
1373
+ # XY-plane transformation matrix ... needs to be clockwise for boost.
1374
+ ft = OpenStudio::Transformation.alignFace(p1)
1375
+ ft_p1 = flatZ( (ft.inverse * p1) )
1376
+ ft_p2 = flatZ( (ft.inverse * p2) )
1377
+ return false if ft_p1.empty?
1378
+ return false if ft_p2.empty?
1379
+ cw = OpenStudio.pointInPolygon(ft_p1.first, ft_p1, TOL)
1380
+ ft_p1 = flatZ( (ft.inverse * p1).reverse ) unless cw
1381
+ ft_p2 = flatZ( (ft.inverse * p2).reverse ) unless cw
1382
+ return false if ft_p1.empty?
1383
+ return false if ft_p2.empty?
1384
+ area1 = OpenStudio.getArea(ft_p1)
1385
+ area2 = OpenStudio.getArea(ft_p2)
1386
+ return empty("#{i1} area", mth, ERR, a) if area1.empty?
1387
+ return empty("#{i2} area", mth, ERR, a) if area2.empty?
1388
+ area1 = area1.get
1389
+ area2 = area2.get
1390
+ union = OpenStudio.join(ft_p1, ft_p2, TOL2)
1391
+ return false if union.empty?
1392
+ union = union.get
1393
+ area = OpenStudio.getArea(union)
1394
+ return empty("#{i1}:#{i2} union area", mth, ERR, a) if area.empty?
1395
+ area = area.get
1396
+ return false if area < TOL
1397
+
1398
+ true
1399
+ end
1400
+
1401
+ ##
1402
+ # Generate offset vertices (by width) for a 3- or 4-sided, convex polygon.
1403
+ #
1404
+ # @param p1 [OpenStudio::Point3dVector] OpenStudio Point3D vector/array
1405
+ # @param w [Float] offset width (min: 0.0254m)
1406
+ # @param v [Integer] OpenStudio SDK version, eg '321' for 'v3.2.1' (optional)
1407
+ #
1408
+ # @return [OpenStudio::Point3dVector] offset points if successful
1409
+ # @return [OpenStudio::Point3dVector] original points if invalid input
1410
+ def offset(p1 = [], w = 0, v = 0)
1411
+ mth = "TBD::#{__callee__}"
1412
+ cl = OpenStudio::Point3d
1413
+ vrsn = OpenStudio.openStudioVersion.split(".").map(&:to_i).join.to_i
1414
+
1415
+ valid = p1.is_a?(OpenStudio::Point3dVector) || p1.is_a?(Array)
1416
+ return mismatch("pts", p1, cl1, mth, DBG, p1) unless valid
1417
+ return empty("pts", mth, ERR, p1) if p1.empty?
1418
+ valid = p1.size == 3 || p1.size == 4
1419
+ iv = true if p1.size == 4
1420
+ return invalid("pts", mth, 1, DBG, p1) unless valid
1421
+ return invalid("width", mth, 2, DBG, p1) unless w.respond_to?(:to_f)
1422
+ w = w.to_f
1423
+ return p1 if w < 0.0254
1424
+ v = v.to_i if v.respond_to?(:to_i)
1425
+ v = 0 unless v.respond_to?(:to_i)
1426
+ v = vrsn if v.zero?
1427
+
1428
+ p1.each { |x| return mismatch("p", x, cl, mth, ERR, p1) unless x.is_a?(cl) }
1429
+
1430
+ unless v < 340
1431
+ # XY-plane transformation matrix ... needs to be clockwise for boost.
1432
+ ft = OpenStudio::Transformation::alignFace(p1)
1433
+ ft_pts = flatZ( (ft.inverse * p1) )
1434
+ return p1 if ft_pts.empty?
1435
+ cw = OpenStudio::pointInPolygon(ft_pts.first, ft_pts, TOL)
1436
+ ft_pts = flatZ( (ft.inverse * p1).reverse ) unless cw
1437
+ offset = OpenStudio.buffer(ft_pts, w, TOL)
1438
+ return p1 if offset.empty?
1439
+ offset = offset.get
1440
+ offset = ft * offset if cw
1441
+ offset = (ft * offset).reverse unless cw
1442
+
1443
+ pz = OpenStudio::Point3dVector.new
1444
+ offset.each { |o| pz << OpenStudio::Point3d.new(o.x, o.y, o.z ) }
1445
+ return pz
1446
+ else # brute force approach
1447
+ pz = {}
1448
+ pz[:A] = {}
1449
+ pz[:B] = {}
1450
+ pz[:C] = {}
1451
+ pz[:D] = {} if iv
1452
+
1453
+ pz[:A][:p] = OpenStudio::Point3d.new(p1[0].x, p1[0].y, p1[0].z)
1454
+ pz[:B][:p] = OpenStudio::Point3d.new(p1[1].x, p1[1].y, p1[1].z)
1455
+ pz[:C][:p] = OpenStudio::Point3d.new(p1[2].x, p1[2].y, p1[2].z)
1456
+ pz[:D][:p] = OpenStudio::Point3d.new(p1[3].x, p1[3].y, p1[3].z) if iv
1457
+
1458
+ pzAp = pz[:A][:p]
1459
+ pzBp = pz[:B][:p]
1460
+ pzCp = pz[:C][:p]
1461
+ pzDp = pz[:D][:p] if iv
1462
+
1463
+ # Generate vector pairs, from next point & from previous point.
1464
+ # :f_n : "from next"
1465
+ # :f_p : "from previous"
1466
+ #
1467
+ #
1468
+ #
1469
+ #
1470
+ #
1471
+ #
1472
+ # A <---------- B
1473
+ # ^
1474
+ # \
1475
+ # \
1476
+ # C (or D)
1477
+ #
1478
+ pz[:A][:f_n] = pzAp - pzBp
1479
+ pz[:A][:f_p] = pzAp - pzCp unless iv
1480
+ pz[:A][:f_p] = pzAp - pzDp if iv
1481
+
1482
+ pz[:B][:f_n] = pzBp - pzCp
1483
+ pz[:B][:f_p] = pzBp - pzAp
1484
+
1485
+ pz[:C][:f_n] = pzCp - pzAp unless iv
1486
+ pz[:C][:f_n] = pzCp - pzDp if iv
1487
+ pz[:C][:f_p] = pzCp - pzBp
1488
+
1489
+ pz[:D][:f_n] = pzDp - pzAp if iv
1490
+ pz[:D][:f_p] = pzDp - pzCp if iv
1491
+
1492
+ # Generate 3D plane from vectors.
1493
+ #
1494
+ #
1495
+ # | <<< 3D plane ... from point A, with normal B>A
1496
+ # |
1497
+ # |
1498
+ # |
1499
+ # <---------- A <---------- B
1500
+ # |\
1501
+ # | \
1502
+ # | \
1503
+ # | C (or D)
1504
+ #
1505
+ pz[:A][:pl_f_n] = OpenStudio::Plane.new(pzAp, pz[:A][:f_n])
1506
+ pz[:A][:pl_f_p] = OpenStudio::Plane.new(pzAp, pz[:A][:f_p])
1507
+
1508
+ pz[:B][:pl_f_n] = OpenStudio::Plane.new(pzBp, pz[:B][:f_n])
1509
+ pz[:B][:pl_f_p] = OpenStudio::Plane.new(pzBp, pz[:B][:f_p])
1510
+
1511
+ pz[:C][:pl_f_n] = OpenStudio::Plane.new(pzCp, pz[:C][:f_n])
1512
+ pz[:C][:pl_f_p] = OpenStudio::Plane.new(pzCp, pz[:C][:f_p])
1513
+
1514
+ pz[:D][:pl_f_n] = OpenStudio::Plane.new(pzDp, pz[:D][:f_n]) if iv
1515
+ pz[:D][:pl_f_p] = OpenStudio::Plane.new(pzDp, pz[:D][:f_p]) if iv
1516
+
1517
+ # Project an extended point (pC) unto 3D plane.
1518
+ #
1519
+ # pC <<< projected unto extended B>A 3D plane
1520
+ # eC |
1521
+ # \ |
1522
+ # \ |
1523
+ # \|
1524
+ # <---------- A <---------- B
1525
+ # |\
1526
+ # | \
1527
+ # | \
1528
+ # | C (or D)
1529
+ #
1530
+ pz[:A][:p_n_pl] = pz[:A][:pl_f_n].project(pz[:A][:p] + pz[:A][:f_p])
1531
+ pz[:A][:n_p_pl] = pz[:A][:pl_f_p].project(pz[:A][:p] + pz[:A][:f_n])
1532
+
1533
+ pz[:B][:p_n_pl] = pz[:B][:pl_f_n].project(pz[:B][:p] + pz[:B][:f_p])
1534
+ pz[:B][:n_p_pl] = pz[:B][:pl_f_p].project(pz[:B][:p] + pz[:B][:f_n])
1535
+
1536
+ pz[:C][:p_n_pl] = pz[:C][:pl_f_n].project(pz[:C][:p] + pz[:C][:f_p])
1537
+ pz[:C][:n_p_pl] = pz[:C][:pl_f_p].project(pz[:C][:p] + pz[:C][:f_n])
1538
+
1539
+ pz[:D][:p_n_pl] = pz[:D][:pl_f_n].project(pz[:D][:p] + pz[:D][:f_p]) if iv
1540
+ pz[:D][:n_p_pl] = pz[:D][:pl_f_p].project(pz[:D][:p] + pz[:D][:f_n]) if iv
1541
+
1542
+ # Generate vector from point (e.g. A) to projected extended point (pC).
1543
+ #
1544
+ # pC
1545
+ # eC ^
1546
+ # \ |
1547
+ # \ |
1548
+ # \|
1549
+ # <---------- A <---------- B
1550
+ # |\
1551
+ # | \
1552
+ # | \
1553
+ # | C (or D)
1554
+ #
1555
+ pz[:A][:n_p_n_pl] = pz[:A][:p_n_pl] - pzAp
1556
+ pz[:A][:n_n_p_pl] = pz[:A][:n_p_pl] - pzAp
1557
+
1558
+ pz[:B][:n_p_n_pl] = pz[:B][:p_n_pl] - pzBp
1559
+ pz[:B][:n_n_p_pl] = pz[:B][:n_p_pl] - pzBp
1560
+
1561
+ pz[:C][:n_p_n_pl] = pz[:C][:p_n_pl] - pzCp
1562
+ pz[:C][:n_n_p_pl] = pz[:C][:n_p_pl] - pzCp
1563
+
1564
+ pz[:D][:n_p_n_pl] = pz[:D][:p_n_pl] - pzDp if iv
1565
+ pz[:D][:n_n_p_pl] = pz[:D][:n_p_pl] - pzDp if iv
1566
+
1567
+ # Fetch angle between both extended vectors (A>pC & A>pB),
1568
+ # ... then normalize (Cn).
1569
+ #
1570
+ # pC
1571
+ # eC ^
1572
+ # \ |
1573
+ # \ Cn
1574
+ # \|
1575
+ # <---------- A <---------- B
1576
+ # |\
1577
+ # | \
1578
+ # | \
1579
+ # | C (or D)
1580
+ #
1581
+ a1 = OpenStudio.getAngle(pz[:A][:n_p_n_pl], pz[:A][:n_n_p_pl])
1582
+ a2 = OpenStudio.getAngle(pz[:B][:n_p_n_pl], pz[:B][:n_n_p_pl])
1583
+ a3 = OpenStudio.getAngle(pz[:C][:n_p_n_pl], pz[:C][:n_n_p_pl])
1584
+ a4 = OpenStudio.getAngle(pz[:D][:n_p_n_pl], pz[:D][:n_n_p_pl]) if iv
1585
+
1586
+ # Generate new 3D points A', B', C' (and D') ... zigzag.
1587
+ #
1588
+ #
1589
+ #
1590
+ #
1591
+ # A' ---------------------- B'
1592
+ # \
1593
+ # \ A <---------- B
1594
+ # \ \
1595
+ # \ \
1596
+ # \ \
1597
+ # C' C
1598
+ pz[:A][:f_n].normalize
1599
+ pz[:A][:n_p_n_pl].normalize
1600
+ pzAp = pzAp + scalar(pz[:A][:n_p_n_pl], w)
1601
+ pzAp = pzAp + scalar(pz[:A][:f_n], w * Math.tan(a1/2))
1602
+
1603
+ pz[:B][:f_n].normalize
1604
+ pz[:B][:n_p_n_pl].normalize
1605
+ pzBp = pzBp + scalar(pz[:B][:n_p_n_pl], w)
1606
+ pzBp = pzBp + scalar(pz[:B][:f_n], w * Math.tan(a2/2))
1607
+
1608
+ pz[:C][:f_n].normalize
1609
+ pz[:C][:n_p_n_pl].normalize
1610
+ pzCp = pzCp + scalar(pz[:C][:n_p_n_pl], w)
1611
+ pzCp = pzCp + scalar(pz[:C][:f_n], w * Math.tan(a3/2))
1612
+
1613
+ pz[:D][:f_n].normalize if iv
1614
+ pz[:D][:n_p_n_pl].normalize if iv
1615
+ pzDp = pzDp + scalar(pz[:D][:n_p_n_pl], w) if iv
1616
+ pzDp = pzDp + scalar(pz[:D][:f_n], w * Math.tan(a4/2)) if iv
1617
+
1618
+ # Re-convert to OpenStudio 3D points.
1619
+ vec = OpenStudio::Point3dVector.new
1620
+ vec << OpenStudio::Point3d.new(pzAp.x, pzAp.y, pzAp.z)
1621
+ vec << OpenStudio::Point3d.new(pzBp.x, pzBp.y, pzBp.z)
1622
+ vec << OpenStudio::Point3d.new(pzCp.x, pzCp.y, pzCp.z)
1623
+ vec << OpenStudio::Point3d.new(pzDp.x, pzDp.y, pzDp.z) if iv
1624
+
1625
+ return vec
1626
+ end
1627
+ end
1628
+
1629
+ ##
1630
+ # Callback when other modules extend OSlg
1631
+ #
1632
+ # @param base [Object] instance or class object
1633
+ def self.extended(base)
1634
+ base.send(:include, self)
1635
+ end
1636
+ end