osut 0.6.0a1__py3-none-any.whl

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.
osut/osut.py ADDED
@@ -0,0 +1,2763 @@
1
+ # BSD 3-Clause License
2
+ #
3
+ # Copyright (c) 2022-2025, rd2
4
+ #
5
+ # Redistribution and use in source and binary forms, with or without
6
+ # modification, are permitted provided that the following conditions are met:
7
+ #
8
+ # 1. Redistributions of source code must retain the above copyright notice, this
9
+ # list of conditions and the following disclaimer.
10
+ #
11
+ # 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ # this list of conditions and the following disclaimer in the documentation
13
+ # and/or other materials provided with the distribution.
14
+ #
15
+ # 3. Neither the name of the copyright holder nor the names of its
16
+ # contributors may be used to endorse or promote products derived from
17
+ # this software without specific prior written permission.
18
+ #
19
+ # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22
+ # DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23
+ # FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24
+ # DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25
+ # SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26
+ # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27
+ # OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28
+ # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
29
+
30
+ import re
31
+ import math
32
+ import collections
33
+ import openstudio
34
+ from oslg import oslg
35
+ from dataclasses import dataclass
36
+
37
+ @dataclass(frozen=True)
38
+ class _CN:
39
+ DBG = oslg.CN.DEBUG
40
+ INF = oslg.CN.INFO
41
+ WRN = oslg.CN.WARN
42
+ ERR = oslg.CN.ERROR
43
+ FTL = oslg.CN.FATAL
44
+ TOL = 0.01 # default distance tolerance (m)
45
+ TOL2 = TOL * TOL # default area tolerance (m2)
46
+ CN = _CN()
47
+
48
+ # General surface orientations (see 'facets' method).
49
+ _sidz = ("bottom", "top", "north", "east", "south", "west")
50
+
51
+ # This first set of utilities support OpenStudio materials, constructions,
52
+ # construction sets, etc. If relying on default StandardOpaqueMaterial:
53
+ # - roughness (rgh) : "Smooth"
54
+ # - thickness : 0.1 m
55
+ # - thermal conductivity (k ) : 0.1 W/m.K
56
+ # - density (rho) : 0.1 kg/m3
57
+ # - specific heat (cp ) : 1400.0 J/kg•K
58
+ #
59
+ # https://s3.amazonaws.com/openstudio-sdk-documentation/cpp/
60
+ # OpenStudio-3.6.1-doc/model/html/
61
+ # classopenstudio_1_1model_1_1_standard_opaque_material.html
62
+
63
+ # ... apart from surface roughness, rarely would these material properties be
64
+ # suitable - and are therefore explicitly set below. On roughness:
65
+ # - "Very Rough" : stucco
66
+ # - "Rough" : brick
67
+ # - "Medium Rough" : concrete
68
+ # - "Medium Smooth" : clear pine
69
+ # - "Smooth" : smooth plaster
70
+ # - "Very Smooth" : glass
71
+
72
+ # Thermal mass categories (e.g. exterior cladding, interior finish, framing).
73
+ # - "none" : token for 'no user selection', resort to defaults
74
+ # - "light" : e.g. 16mm drywall interior
75
+ # - "medium" : e.g. 100mm brick cladding
76
+ # - "heavy" : e.g. 200mm poured concrete
77
+ _mass = ("none", "light", "medium", "heavy")
78
+
79
+ # Basic materials (StandardOpaqueMaterials only).
80
+ _mats = dict(
81
+ material = {}, # generic, e.g. lightweight cladding over furring, fibreboard
82
+ sand = {},
83
+ concrete = {},
84
+ brick = {},
85
+ drywall = {}, # e.g. finished drywall, intermediate sheating
86
+ mineral = {}, # e.g. light, semi-rigid rock wool insulation
87
+ polyiso = {}, # e.g. polyisocyanurate panel (or similar)
88
+ cellulose = {}, # e.g. blown, dry/stabilized fibre
89
+ door = {} # single composite material (45mm insulated steel door)
90
+ )
91
+
92
+ # Default inside + outside air film resistances (m2.K/W).
93
+ _film = dict(
94
+ shading = 0.000, # NA
95
+ partition = 0.150, # uninsulated wood- or steel-framed wall
96
+ wall = 0.150, # un/insulated wall
97
+ roof = 0.140, # un/insulated roof
98
+ floor = 0.190, # un/insulated (exposed) floor
99
+ basement = 0.120, # un/insulated basement wall
100
+ slab = 0.160, # un/insulated basement slab or slab-on-grade
101
+ door = 0.150, # standard, 45mm insulated steel (opaque) door
102
+ window = 0.150, # vertical fenestration, e.g. glazed doors, windows
103
+ skylight = 0.140 # e.g. domed 4' x 4' skylight
104
+ )
105
+
106
+ # Default (~1980s) envelope Uo (W/m2•K), based on surface type.
107
+ _uo = dict(
108
+ shading = None, # N/A
109
+ partition = None, # N/A
110
+ wall = 0.384, # rated R14.8 hr•ft2F/Btu
111
+ roof = 0.327, # rated R17.6 hr•ft2F/Btu
112
+ floor = 0.317, # rated R17.9 hr•ft2F/Btu (exposed floor)
113
+ basement = None,
114
+ slab = None,
115
+ door = 1.800, # insulated, unglazed steel door (single layer)
116
+ window = 2.800, # e.g. patio doors (simple glazing)
117
+ skylight = 3.500 # all skylight technologies
118
+ )
119
+
120
+ # Standard opaque materials, taken from a variety of sources (e.g. energy
121
+ # codes, NREL's BCL).
122
+ # - sand
123
+ # - concrete
124
+ # - brick
125
+ #
126
+ # Material properties remain largely constant between projects. What does
127
+ # tend to vary (between projects) are thicknesses. Actual OpenStudio opaque
128
+ # material objects can be (re)set in more than one way by class methods.
129
+ # In genConstruction, OpenStudio object identifiers are later suffixed with
130
+ # actual material thicknesses, in mm, e.g.:
131
+ # - "concrete200" : 200mm concrete slab
132
+ # - "drywall13" : 1/2" gypsum board
133
+ # - "drywall16" : 5/8" gypsum board
134
+ #
135
+ # Surface absorptances are also defaulted in OpenStudio:
136
+ # - thermal, long-wave (thm) : 90%
137
+ # - solar (sol) : 70%
138
+ # - visible (vis) : 70%
139
+ #
140
+ # These can also be explicitly set (see "sand").
141
+ _mats["material" ]["rgh"] = "MediumSmooth"
142
+ _mats["material" ]["k" ] = 0.115
143
+ _mats["material" ]["rho"] = 540.000
144
+ _mats["material" ]["cp" ] = 1200.000
145
+
146
+ _mats["sand" ]["rgh"] = "Rough"
147
+ _mats["sand" ]["k" ] = 1.290
148
+ _mats["sand" ]["rho"] = 2240.000
149
+ _mats["sand" ]["cp" ] = 830.000
150
+ _mats["sand" ]["thm"] = 0.900
151
+ _mats["sand" ]["sol"] = 0.700
152
+ _mats["sand" ]["vis"] = 0.700
153
+
154
+ _mats["concrete" ]["rgh"] = "MediumRough"
155
+ _mats["concrete" ]["k" ] = 1.730
156
+ _mats["concrete" ]["rho"] = 2240.000
157
+ _mats["concrete" ]["cp" ] = 830.000
158
+
159
+ _mats["brick" ]["rgh"] = "Rough"
160
+ _mats["brick" ]["k" ] = 0.675
161
+ _mats["brick" ]["rho"] = 1600.000
162
+ _mats["brick" ]["cp" ] = 790.000
163
+
164
+ _mats["drywall" ]["k" ] = 0.160
165
+ _mats["drywall" ]["rho"] = 785.000
166
+ _mats["drywall" ]["cp" ] = 1090.000
167
+
168
+ _mats["mineral" ]["k" ] = 0.050
169
+ _mats["mineral" ]["rho"] = 19.000
170
+ _mats["mineral" ]["cp" ] = 960.000
171
+
172
+ _mats["polyiso" ]["k" ] = 0.025
173
+ _mats["polyiso" ]["rho"] = 25.000
174
+ _mats["polyiso" ]["cp" ] = 1590.000
175
+
176
+ _mats["cellulose"]["rgh"] = "VeryRough"
177
+ _mats["cellulose"]["k" ] = 0.050
178
+ _mats["cellulose"]["rho"] = 80.000
179
+ _mats["cellulose"]["cp" ] = 835.000
180
+
181
+ _mats["door" ]["rgh"] = "MediumSmooth"
182
+ _mats["door" ]["k" ] = 0.080
183
+ _mats["door" ]["rho"] = 600.000
184
+ _mats["door" ]["cp" ] = 1000.000
185
+
186
+
187
+ def sidz() -> tuple:
188
+ """Returns available 'sidz' keywords."""
189
+ return _sidz
190
+
191
+
192
+ def mass() -> tuple:
193
+ """Returns available 'mass' keywords."""
194
+ return _mass
195
+
196
+
197
+ def mats() -> dict:
198
+ """Returns stored materials dictionary."""
199
+ return _mats
200
+
201
+
202
+ def film() -> dict:
203
+ """Returns inside + outside air film resistance dictionary."""
204
+ return _film
205
+
206
+
207
+ def uo() -> dict:
208
+ """Returns (surface type-specific) Uo dictionary."""
209
+ return _uo
210
+
211
+
212
+ def genConstruction(model=None, specs=dict()):
213
+ """Generates an OpenStudio multilayered construction, + materials if needed.
214
+
215
+ Args:
216
+ specs:
217
+ A dictionary holding multilayered construction parameters:
218
+ - "id" (str): construction identifier
219
+ - "type" (str): surface type - see OSut 'uo()'
220
+ - "uo" (float): assembly clear-field Uo, in W/m2•K - see OSut 'uo()'
221
+ - "clad" (str): exterior cladding - see OSut 'mass()'
222
+ - "frame" (str): assembly framing - see OSut 'mass()'
223
+ - "finish" (str): interior finish - see OSut 'mass()'
224
+
225
+ Returns:
226
+ openstudio.model.Construction: A generated construction.
227
+ None: If invalid inputs (see logs).
228
+
229
+ """
230
+ mth = "osut.genConstruction"
231
+ cl = openstudio.model.Model
232
+
233
+ if not isinstance(model, cl):
234
+ return oslg.mismatch("model", model, cl, mth, CN.DBG)
235
+ if not isinstance(specs, dict):
236
+ return oslg.mismatch("specs", specs, dict, mth, CN.DBG)
237
+
238
+ if "type" not in specs: specs["type"] = "wall"
239
+ if "id" not in specs: specs["id" ] = ""
240
+
241
+ id = oslg.trim(specs["id"])
242
+
243
+ if not id:
244
+ id = "OSut.CON." + specs["type"]
245
+ if specs["type"] not in uo():
246
+ return oslg.invalid("surface type", mth, 2, CN.ERR)
247
+ if "uo" not in specs:
248
+ specs["uo"] = uo()[ specs["type"] ]
249
+
250
+ u = specs["uo"]
251
+
252
+ if u:
253
+ try:
254
+ u = float(u)
255
+ except:
256
+ return oslg.mismatch(id + " Uo", u, float, mth, CN.ERR)
257
+
258
+ if u < 0:
259
+ return oslg.negative(id + " Uo", mth, CN.ERR)
260
+ if u > 5.678:
261
+ return oslg.invalid(id + " Uo (> 5.678)", mth, 2, CN.ERR)
262
+
263
+ # Optional specs. Log/reset if invalid.
264
+ if "clad" not in specs: specs["clad" ] = "light" # exterior
265
+ if "frame" not in specs: specs["frame" ] = "light"
266
+ if "finish" not in specs: specs["finish"] = "light" # interior
267
+ if specs["clad" ] not in mass(): oslg.log(CN.WRN, "Reset: light cladding")
268
+ if specs["frame" ] not in mass(): oslg.log(CN.WRN, "Reset: light framing")
269
+ if specs["finish"] not in mass(): oslg.log(CN.WRN, "Reset: light finish")
270
+ if specs["clad" ] not in mass(): specs["clad" ] = "light"
271
+ if specs["frame" ] not in mass(): specs["frame" ] = "light"
272
+ if specs["frame" ] not in mass(): specs["finish"] = "light"
273
+
274
+ flm = film()[ specs["type"] ]
275
+
276
+ # Layered assembly (max 4 layers):
277
+ # - cladding
278
+ # - intermediate sheathing
279
+ # - composite insulating/framing
280
+ # - interior finish
281
+ a = dict(clad={}, sheath={}, compo={}, finish={}, glazing={})
282
+
283
+ if specs["type"] == "shading":
284
+ mt = "material"
285
+ d = 0.015
286
+ a["compo"]["mat"] = mats()[mt]
287
+ a["compo"]["d" ] = d
288
+ a["compo"]["id" ] = "OSut." + mt + ".%03d" % int(d * 1000)
289
+
290
+ elif specs["type"] == "partition":
291
+ if not specs["clad"] == "none":
292
+ mt = "drywall"
293
+ d = 0.015
294
+ a["clad"]["mat"] = mats()[mt]
295
+ a["clad"]["d" ] = d
296
+ a["clad"]["id" ] = "OSut." + mt + ".%03d" % int(d * 1000)
297
+
298
+ mt = "concrete"
299
+ d = 0.015
300
+ if specs["frame"] == "light": mt = "material"
301
+ if u: mt = "mineral"
302
+ if specs["frame"] == "medium": d = 0.100
303
+ if specs["frame"] == "heavy": d = 0.200
304
+ if u: d = 0.100
305
+ a["compo"]["mat"] = mats()[mt]
306
+ a["compo"]["d" ] = d
307
+ a["compo"]["id" ] = "OSut." + mt + ".%03d" % int(d * 1000)
308
+
309
+ if not specs["finish"] == "none":
310
+ mt = "drywall"
311
+ d = 0.015
312
+ a["finish"]["mat"] = mats()[mt]
313
+ a["finish"]["d" ] = d
314
+ a["finish"]["id" ] = "OSut." + mt + ".%03d" % int(d * 1000)
315
+
316
+ elif specs["type"] == "wall":
317
+ if not specs["clad"] == "none":
318
+ mt = "material"
319
+ d = 0.100
320
+ if specs["clad"] == "medium": mt = "brick"
321
+ if specs["clad"] == "heavy": mt = "concrete"
322
+ if specs["clad"] == "light": d = 0.015
323
+ a["clad"]["mat"] = mats()[mt]
324
+ a["clad"]["d" ] = d
325
+ a["clad"]["id" ] = "OSut." + mt + ".%03d" % int(d * 1000)
326
+
327
+ mt = "drywall"
328
+ d = 0.100
329
+ if specs["frame"] == "medium": mt = "mineral"
330
+ if specs["frame"] == "heavy": mt = "polyiso"
331
+ if specs["frame"] == "light": d = 0.015
332
+ a["sheath"]["mat"] = mats()[mt]
333
+ a["sheath"]["d" ] = d
334
+ a["sheath"]["id" ] = "OSut." + mt + ".%03d" % int(d * 1000)
335
+
336
+ mt = "mineral"
337
+ d = 0.100
338
+ if specs["frame"] == "medium": mt = "cellulose"
339
+ if specs["frame"] == "heavy": mt = "concrete"
340
+ if not u: mt = "material"
341
+ if specs["frame"] == "heavy": d = 0.200
342
+ if not u: d = 0.015
343
+ a["compo"]["mat"] = mats()[mt]
344
+ a["compo"]["d" ] = d
345
+ a["compo"]["id" ] = "OSut." + mt + ".%03d" % int(d * 1000)
346
+
347
+ if not specs["finish"] == "none":
348
+ mt = "concrete"
349
+ d = 0.015
350
+ if specs["finish"] == "light": mt = "drywall"
351
+ if specs["finish"] == "medium": d = 0.100
352
+ if specs["finish"] == "heavy": d = 0.200
353
+ a["finish"]["mat"] = mats()[mt]
354
+ a["finish"]["d" ] = d
355
+ a["finish"]["id" ] = "OSut." + mt + ".%03d" % int(d * 1000)
356
+
357
+ elif specs["type"] == "roof":
358
+ if not specs["clad"] == "none":
359
+ mt = "concrete"
360
+ d = 0.015
361
+ if specs["clad"] == "light": mt = "material"
362
+ if specs["clad"] == "medium": d = 0.100 # e.g. terrace
363
+ if specs["clad"] == "heavy": d = 0.200 # e.g. parking garage
364
+ a["clad"]["mat"] = mats()[mt]
365
+ a["clad"]["d" ] = d
366
+ a["clad"]["id" ] = "OSut." + mt + ".%03d" % int(d * 1000)
367
+
368
+ mt = "mineral"
369
+ d = 0.100
370
+ if specs["frame"] == "medium": mt = "polyiso"
371
+ if specs["frame"] == "heavy": mt = "cellulose"
372
+ if not u: mt = "material"
373
+ if not u: d = 0.015
374
+ a["compo"]["mat"] = mats()[mt]
375
+ a["compo"]["d" ] = d
376
+ a["compo"]["id" ] = "OSut." + mt + ".%03d" % int(d * 1000)
377
+
378
+ if not specs["finish"] == "none":
379
+ mt = "concrete"
380
+ d = 0.015
381
+ if specs["finish"] == "light": mt = "drywall"
382
+ if specs["finish"] == "medium": d = 0.100 # proxy for steel decking
383
+ if specs["finish"] == "heavy": d = 0.200
384
+ a["finish"]["mat"] = mats()[mt]
385
+ a["finish"]["d" ] = d
386
+ a["finish"]["id" ] = "OSut." + mt + ".%03d" % int(d * 1000)
387
+
388
+ elif specs["type"] == "floor":
389
+ if not specs["clad"] == "none":
390
+ mt = "material"
391
+ d = 0.015
392
+ a["clad"]["mat"] = mats()[mt]
393
+ a["clad"]["d" ] = d
394
+ a["clad"]["id" ] = "OSut." + mt + ".%03d" % int(d * 1000)
395
+
396
+ mt = "mineral"
397
+ d = 0.100
398
+ if specs["frame"] == "medium": mt = "polyiso"
399
+ if specs["frame"] == "heavy": mt = "cellulose"
400
+ if not u: mt = "material"
401
+ if not u: d = 0.015
402
+ a["compo"]["mat"] = mats()[mt]
403
+ a["compo"]["d" ] = d
404
+ a["compo"]["id" ] = "OSut." + mt + ".%03d" % int(d * 1000)
405
+
406
+ if not specs["finish"] == "none":
407
+ mt = "concrete"
408
+ d = 0.015
409
+ if specs["finish"] == "light": mt = "material"
410
+ if specs["finish"] == "medium": d = 0.100
411
+ if specs["finish"] == "heavy": d = 0.200
412
+ a["finish"]["mat"] = mats()[mt]
413
+ a["finish"]["d" ] = d
414
+ a["finish"]["id" ] = "OSut." + mt + ".%03d" % int(d * 1000)
415
+
416
+ elif specs["type"] == "slab":
417
+ mt = "sand"
418
+ d = 0.100
419
+ a["clad"]["mat"] = mats()[mt]
420
+ a["clad"]["d" ] = d
421
+ a["clad"]["id" ] = "OSut." + mt + ".%03d" % int(d * 1000)
422
+
423
+ if not specs["frame"] == "none":
424
+ mt = "polyiso"
425
+ d = 0.025
426
+ a["sheath"]["mat"] = mats()[mt]
427
+ a["sheath"]["d" ] = d
428
+ a["sheath"]["id" ] = "OSut." + mt + ".%03d" % int(d * 1000)
429
+
430
+ mt = "concrete"
431
+ d = 0.100
432
+ if specs["frame"] == "heavy": d = 0.200
433
+ a["compo"]["mat"] = mats()[mt]
434
+ a["compo"]["d" ] = d
435
+ a["compo"]["id" ] = "OSut." + mt + ".%03d" % int(d * 1000)
436
+
437
+ if not specs["finish"] == "none":
438
+ mt = "material"
439
+ d = 0.015
440
+ a["finish"]["mat"] = mats()[mt]
441
+ a["finish"]["d" ] = d
442
+ a["finish"]["id" ] = "OSut." + mt + ".%03d" % int(d * 1000)
443
+
444
+ elif specs["type"] == "basement":
445
+ if not specs["clad"] == "none":
446
+ mt = "concrete"
447
+ d = 0.100
448
+ if specs["clad"] == "light": mt = "material"
449
+ if specs["clad"] == "light": d = 0.015
450
+ a["clad"]["mat"] = mats[mt]
451
+ a["clad"]["d" ] = d
452
+ a["clad"]["id" ] = "OSut." + mt + ".%03d" % int(d * 1000)
453
+
454
+ mt = "polyiso"
455
+ d = 0.025
456
+ a["sheath"]["mat"] = mats()[mt]
457
+ a["sheath"]["d" ] = d
458
+ a["sheath"]["id" ] = "OSut." + mt + ".%03d" % int(d * 1000)
459
+
460
+ mt = "concrete"
461
+ d = 0.200
462
+ a["compo"]["mat"] = mats()[mt]
463
+ a["compo"]["d" ] = d
464
+ a["compo"]["id" ] = "OSut." + mt + ".%03d" % int(d * 1000)
465
+ else:
466
+ mt = "concrete"
467
+ d = 0.200
468
+ a["sheath"]["mat"] = mats()[mt]
469
+ a["sheath"]["d" ] = d
470
+ a["sheath"]["id" ] = "OSut." + mt + ".%03d" % int(d * 1000)
471
+
472
+ if not specs["finish"] == "none":
473
+ mt = "mineral"
474
+ d = 0.075
475
+ a["compo"]["mat"] = mats()[mt]
476
+ a["compo"]["d" ] = d
477
+ a["compo"]["id" ] = "OSut." + mt + ".%03d" % int(d * 1000)
478
+
479
+ mt = "drywall"
480
+ d = 0.015
481
+ a["finish"]["mat"] = mats()[mt]
482
+ a["finish"]["d" ] = d
483
+ a["finish"]["id" ] = "OSut." + mt + ".%03d" % int(d * 1000)
484
+
485
+ elif specs["type"] == "door":
486
+ mt = "door"
487
+ d = 0.045
488
+ a["compo" ]["mat" ] = mats()[mt]
489
+ a["compo" ]["d" ] = d
490
+ a["compo" ]["id" ] = "OSut." + mt + ".%03d" % int(d * 1000)
491
+
492
+ elif specs["type"] == "window":
493
+ a["glazing"]["u" ] = specs["uo"]
494
+ a["glazing"]["shgc"] = 0.450
495
+ if "shgc" in specs: a["glazing"]["shgc"] = specs["shgc"]
496
+ a["glazing"]["id" ] = "OSut.window"
497
+ a["glazing"]["id" ] += ".U%.1f" % a["glazing"]["u"]
498
+ a["glazing"]["id" ] += ".SHGC%d" % (a["glazing"]["shgc"]*100)
499
+
500
+ elif specs["type"] == "skylight":
501
+ a["glazing"]["u" ] = specs["uo"]
502
+ a["glazing"]["shgc"] = 0.450
503
+ if "shgc" in specs: a["glazing"]["shgc"] = specs["shgc"]
504
+ a["glazing"]["id" ] = "OSut.skylight"
505
+ a["glazing"]["id" ] += ".U%.1f" % a["glazing"]["u"]
506
+ a["glazing"]["id" ] += ".SHGC%d" % (a["glazing"]["shgc"]*100)
507
+
508
+ if a["glazing"]:
509
+ layers = openstudio.model.FenestrationMaterialVector()
510
+
511
+ u = a["glazing"]["u" ]
512
+ shgc = a["glazing"]["shgc"]
513
+ lyr = model.getSimpleGlazingByName(a["glazing"]["id"])
514
+
515
+ if lyr:
516
+ lyr = lyr.get()
517
+ else:
518
+ lyr = openstudio.model.SimpleGlazing(model, u, shgc)
519
+ lyr.setName(a["glazing"]["id"])
520
+
521
+ layers.append(lyr)
522
+ else:
523
+ layers = openstudio.model.OpaqueMaterialVector()
524
+
525
+ # Loop through each layer spec, and generate construction.
526
+ for i, l in a.items():
527
+ if not l: continue
528
+
529
+ lyr = model.getStandardOpaqueMaterialByName(l["id"])
530
+
531
+ if lyr:
532
+ lyr = lyr.get()
533
+ else:
534
+ lyr = openstudio.model.StandardOpaqueMaterial(model)
535
+ lyr.setName(l["id"])
536
+ lyr.setThickness(l["d"])
537
+ if "rgh" in l["mat"]: lyr.setRoughness(l["mat"]["rgh"])
538
+ if "k" in l["mat"]: lyr.setConductivity(l["mat"]["k"])
539
+ if "rho" in l["mat"]: lyr.setDensity(l["mat"]["rho"])
540
+ if "cp" in l["mat"]: lyr.setSpecificHeat(l["mat"]["cp" ])
541
+ if "thm" in l["mat"]: lyr.setThermalAbsorptance(l["mat"]["thm"])
542
+ if "sol" in l["mat"]: lyr.setSolarAbsorptance(l["mat"]["sol"])
543
+ if "vis" in l["mat"]: lyr.setVisibleAbsorptance(l["mat"]["vis"])
544
+
545
+ layers.append(lyr)
546
+
547
+ c = openstudio.model.Construction(layers)
548
+ c.setName(id)
549
+
550
+ # Adjust insulating layer thickness or conductivity to match requested Uo.
551
+ if not a["glazing"]:
552
+ ro = 1 / specs["uo"] - film()[specs["type"]] if specs["uo"] else 0
553
+
554
+ if specs["type"] == "door": # 1x layer, adjust conductivity
555
+ layer = c.getLayer(0).to_StandardOpaqueMaterial()
556
+
557
+ if not layer:
558
+ return oslg.invalid(id + " standard material?", mth, 0)
559
+
560
+ layer = layer.get()
561
+ k = layer.thickness() / ro
562
+ layer.setConductivity(k)
563
+
564
+ elif ro > 0: # multiple layers, adjust insulating layer thickness
565
+ lyr = insulatingLayer(c)
566
+
567
+ if not lyr["index"] or not lyr["type"] or not lyr["r"]:
568
+ return oslg.invalid(id + " construction", mth, 0)
569
+
570
+ index = lyr["index"]
571
+ layer = c.getLayer(index).to_StandardOpaqueMaterial()
572
+
573
+ if not layer:
574
+ return oslg.invalid(id + " material %d" % index, mth, 0)
575
+
576
+ layer = layer.get()
577
+ k = layer.conductivity()
578
+ d = (ro - rsi(c) + lyr["r"]) * k
579
+
580
+ if d < 0.03:
581
+ return oslg.invalid(id + " adjusted material thickness", mth, 0)
582
+
583
+ nom = re.sub(r'[^a-zA-Z]', '', layer.nameString())
584
+ nom = re.sub(r'OSut', '', nom)
585
+ nom = "OSut." + nom + ".%03d" % int(d * 1000)
586
+
587
+ if not model.getStandardOpaqueMaterialByName(nom):
588
+ layer.setName(nom)
589
+ layer.setThickness(d)
590
+
591
+ return c
592
+
593
+
594
+ def genShade(subs=None) -> bool:
595
+ """Generates solar shade(s) (e.g. roller, textile) for glazed OpenStudio
596
+ SubSurfaces (v321+), controlled to minimize overheating in cooling months
597
+ (May to October in Northern Hemisphere), when outdoor dry bulb temperature
598
+ is above 18°C and impinging solar radiation is above 100 W/m2.
599
+
600
+ Args:
601
+ subs:
602
+ A list of sub surfaces.
603
+
604
+ Returns:
605
+ True: If shade successfully generated.
606
+ False: If invalid input (see logs).
607
+
608
+ """
609
+ # Filter OpenStudio warnings for ShadingControl:
610
+ # ref: https://github.com/NREL/OpenStudio/issues/4911
611
+ # str = ".*(?<!ShadingControl)$"
612
+ # openstudio.Logger().instance().standardOutLogger().setChannelRegex(str)
613
+
614
+ mth = "osut.genShade"
615
+ cl = openstudio.model.SubSurfaceVector
616
+
617
+ if int("".join(openstudio.openStudioVersion().split("."))) < 321:
618
+ return False
619
+ if not isinstance(subs, cl):
620
+ return oslg.mismatch("subs", subs, cl, mth, CN.DBG, False)
621
+ if not subs:
622
+ return oslg.empty("subs", mth, CN.WRN, False)
623
+
624
+ # Shading availability period.
625
+ model = subs[0].model()
626
+ id = "onoff"
627
+ onoff = model.getScheduleTypeLimitsByName(id)
628
+
629
+ if onoff:
630
+ onoff = onoff.get()
631
+ else:
632
+ onoff = openstudio.model.ScheduleTypeLimits(model)
633
+ onoff.setName(id)
634
+ onoff.setLowerLimitValue(0)
635
+ onoff.setUpperLimitValue(1)
636
+ onoff.setNumericType("Discrete")
637
+ onoff.setUnitType("Availability")
638
+
639
+ # Shading schedule.
640
+ id = "OSut.SHADE.Ruleset"
641
+ sch = model.getScheduleRulesetByName(id)
642
+
643
+ if sch:
644
+ sch = sch.get()
645
+ else:
646
+ sch = openstudio.model.ScheduleRuleset(model, 0)
647
+ sch.setName(id)
648
+ sch.setScheduleTypeLimits(onoff)
649
+ sch.defaultDaySchedule.setName("OSut.SHADE.Ruleset.Default")
650
+
651
+ # Summer cooling rule.
652
+ id = "OSut.SHADE.ScheduleRule"
653
+ rule = model.getScheduleRuleByName(id)
654
+
655
+ if rule:
656
+ rule = rule.get()
657
+ else:
658
+ may = openstudio.MonthOfYear("May")
659
+ october = openstudio.MonthOfYear("Oct")
660
+ start = openstudio.Date(may, 1)
661
+ finish = openstudio.Date(october, 31)
662
+
663
+ rule = openstudio.model.ScheduleRule(sch)
664
+ rule.setName(id)
665
+ rule.setStartDate(start)
666
+ rule.setEndDate(finish)
667
+ rule.setApplyAllDays(True)
668
+ rule.daySchedule.setName("OSut.SHADE.Rule.Default")
669
+ rule.daySchedule.addValue(openstudio.Time(0,24,0,0), 1)
670
+
671
+ # Shade object.
672
+ id = "OSut.SHADE"
673
+ shd = mdl.getShadeByName(id)
674
+
675
+ if shd:
676
+ shd = shd.get()
677
+ else:
678
+ shd = openstudio.model.Shade(mdl)
679
+ shd.setName(id)
680
+
681
+ # Shading control (unique to each call).
682
+ id = "OSut.ShadingControl"
683
+ ctl = openstudio.model.ShadingControl(shd)
684
+ ctl.setName(id)
685
+ ctl.setSchedule(sch)
686
+ ctl.setShadingControlType("OnIfHighOutdoorAirTempAndHighSolarOnWindow")
687
+ ctl.setSetpoint(18) # °C
688
+ ctl.setSetpoint2(100) # W/m2
689
+ ctl.setMultipleSurfaceControlType("Group")
690
+ ctl.setSubSurfaces(subs)
691
+
692
+ return True
693
+
694
+
695
+ def genMass(sps=None, ratio=2.0) -> bool:
696
+ """ Generates an internal mass definition and instances for target spaces.
697
+ This is largely adapted from OpenStudio-Standards:
698
+ https://github.com/NREL/openstudio-standards/blob/
699
+ eac3805a65be060b39ecaf7901c908f8ed2c051b/lib/openstudio-standards/
700
+ prototypes/common/objects/Prototype.Model.rb#L572
701
+
702
+ Args:
703
+ sps (OpenStudio::Model::SpaceVector):
704
+ Target spaces.
705
+ ratio (float):
706
+ Ratio of internal mass surface area to floor surface area.
707
+
708
+ Returns:
709
+ bool: Whether successfully generated.
710
+ False: If invalid inputs (see logs).
711
+
712
+ """
713
+ mth = "osut.genMass"
714
+ cl = openstudio.model.SpaceVector
715
+
716
+ if not isinstance(sps, cl):
717
+ return oslg.mismatch("spaces", sps, cl, mth, CN.DBG, False)
718
+
719
+ try:
720
+ ratio = float(ratio)
721
+ except:
722
+ return oslg.mismatch("ratio", ratio, float, mth, CN.DBG, False)
723
+
724
+ if not sps:
725
+ return oslg.empty("spaces", mth, CN.DBG, False)
726
+ if ratio < 0:
727
+ return oslg.negative("ratio", mth, CN.ERR, False)
728
+
729
+ # A single material.
730
+ mdl = sps[0].model()
731
+ id = "OSut.MASS.Material"
732
+ mat = mdl.getOpaqueMaterialByName(id)
733
+
734
+ if mat:
735
+ mat = mat.get()
736
+ else:
737
+ mat = openstudio.model.StandardOpaqueMaterial(mdl)
738
+ mat.setName(id)
739
+ mat.setRoughness("MediumRough")
740
+ mat.setThickness(0.15)
741
+ mat.setConductivity(1.12)
742
+ mat.setDensity(540)
743
+ mat.setSpecificHeat(1210)
744
+ mat.setThermalAbsorptance(0.9)
745
+ mat.setSolarAbsorptance(0.7)
746
+ mat.setVisibleAbsorptance(0.17)
747
+
748
+ # A single, 1x layered construction.
749
+ id = "OSut.MASS.Construction"
750
+ con = mdl.getConstructionByName(id)
751
+
752
+ if con:
753
+ con = con.get()
754
+ else:
755
+ con = openstudio.model.Construction(mdl)
756
+ con.setName(id)
757
+ layers = openstudio.model.MaterialVector()
758
+ layers.append(mat)
759
+ con.setLayers(layers)
760
+
761
+ id = "OSut.InternalMassDefinition.%.2f" % ratio
762
+ df = mdl.getInternalMassDefinitionByName(id)
763
+
764
+ if df:
765
+ df = df.get
766
+ else:
767
+ df = openstudio.model.InternalMassDefinition(mdl)
768
+ df.setName(id)
769
+ df.setConstruction(con)
770
+ df.setSurfaceAreaperSpaceFloorArea(ratio)
771
+
772
+ for sp in sps:
773
+ mass = openstudio.model.InternalMass(df)
774
+ mass.setName("OSut.InternalMass.%s" % sp.nameString())
775
+ mass.setSpace(sp)
776
+
777
+ return True
778
+
779
+
780
+ def holdsConstruction(set=None, base=None, gr=False, ex=False, type=""):
781
+ """Validates whether a default construction set holds a base construction.
782
+
783
+ Args:
784
+ set (openstudio.model.DefaultConstructionSet):
785
+ A default construction set.
786
+ base (openstudio.model.ConstructionBase):
787
+ A construction base.
788
+ gr (bool):
789
+ Whether ground-facing surface.
790
+ ex (bool):
791
+ Whether exterior-facing surface.
792
+ type:
793
+ An OpenStudio surface (or sub surface) type (e.g. "Wall").
794
+
795
+ Returns:
796
+ bool: Whether default set holds construction.
797
+ False: If invalid input (see logs).
798
+
799
+ """
800
+ mth = "osut.holdsConstruction"
801
+ cl1 = openstudio.model.DefaultConstructionSet
802
+ cl2 = openstudio.model.ConstructionBase
803
+ t1 = openstudio.model.Surface.validSurfaceTypeValues()
804
+ t2 = openstudio.model.SubSurface.validSubSurfaceTypeValues()
805
+ t1 = [t.lower() for t in t1]
806
+ t2 = [t.lower() for t in t2]
807
+ c = None
808
+
809
+ if not isinstance(set, cl1):
810
+ return oslg.mismatch("set", set, cl1, mth, CN.DBG, False)
811
+ if not isinstance(base, cl2):
812
+ return oslg.mismatch("base", base, cl2, mth, CN.DBG, False)
813
+ if not isinstance(gr, bool):
814
+ return oslg.mismatch("ground", gr, bool, mth, CN.DBG, False)
815
+ if not isinstance(ex, bool):
816
+ return oslg.mismatch("exterior", ex, bool, mth, CN.DBG, False)
817
+
818
+ try:
819
+ type = str(type)
820
+ except:
821
+ return oslg.mismatch("surface type", type, str, mth, CN.DBG, False)
822
+
823
+ type = type.lower()
824
+
825
+ if type in t1:
826
+ if gr:
827
+ if set.defaultGroundContactSurfaceConstructions():
828
+ c = set.defaultGroundContactSurfaceConstructions().get()
829
+ elif ex:
830
+ if set.defaultExteriorSurfaceConstructions():
831
+ c = set.defaultExteriorSurfaceConstructions().get()
832
+ else:
833
+ if set.defaultInteriorSurfaceConstructions():
834
+ c = set.defaultInteriorSurfaceConstructions().get()
835
+ elif type in t2:
836
+ if gr:
837
+ return False
838
+ if ex:
839
+ if set.defaultExteriorSubSurfaceConstructions():
840
+ c = set.defaultExteriorSubSurfaceConstructions().get()
841
+ else:
842
+ if set.defaultInteriorSubSurfaceConstructions():
843
+ c = set.defaultInteriorSubSurfaceConstructions().get()
844
+ else:
845
+ return oslg.invalid("surface type", mth, 5, CN.DBG, False)
846
+
847
+ if not c: return False
848
+
849
+ if type in t1:
850
+ if type == "roofceiling":
851
+ if c.roofCeilingConstruction():
852
+ if c.roofCeilingConstruction().get() == base: return True
853
+ elif type == "floor":
854
+ if c.floorConstruction():
855
+ if c.floorConstruction().get() == base: return True
856
+ else: # "wall"
857
+ if c.wallConstruction():
858
+ if c.wallConstruction().get() == base: return True
859
+ else: # t2
860
+ if type == "tubulardaylightdiffuser":
861
+ if c.tubularDaylightDiffuserConstruction():
862
+ if c.tubularDaylightDiffuserConstruction() == base: return True
863
+ elif type == "tubulardaylightdome":
864
+ if c.tubularDaylightDomeConstruction():
865
+ if c.tubularDaylightDomeConstruction().get() == base: return True
866
+ elif type == "skylight":
867
+ if c.overheadDoorConstruction():
868
+ if c.overheadDoorConstruction().get() == base: return True
869
+ elif type == "glassdoor":
870
+ if c.glassDoorConstruction():
871
+ if c.glassDoorConstruction().get() == base: return True
872
+ elif type == "door":
873
+ if c.doorConstruction():
874
+ if c.doorConstruction().get() == base: return True
875
+ elif type == "operablewindow":
876
+ if c.operableWindowConstruction():
877
+ if c.operableWindowConstruction().get() == base: return True
878
+ else: # "fixedwindow"
879
+ if c.fixedWindowConstruction():
880
+ if c.fixedWindowConstruction().get() == base: return True
881
+
882
+ return False
883
+
884
+
885
+ def defaultConstructionSet(s=None):
886
+ """Returns a surface's default construction set.
887
+
888
+ Args:
889
+ s (openstudio.model.Surface):
890
+ A surface.
891
+
892
+ Returns:
893
+ openstudio.model.DefaultConstructionSet: A default construction set.
894
+ None: If invalid inputs (see logs).
895
+
896
+ """
897
+ mth = "osut.defaultConstructionSet"
898
+ cl = openstudio.model.Surface
899
+
900
+ if not isinstance(s, cl):
901
+ return oslg.mismatch("surface", s, cl, mth)
902
+ if not s.isConstructionDefaulted():
903
+ oslg.log(CN.WRN, "construction not defaulted (%s)" % mth)
904
+ return None
905
+ if not s.construction():
906
+ return oslg.empty("construction", mth, CN.WRN)
907
+ if not s.space():
908
+ return oslg.empty("space", mth, CN.WRN)
909
+
910
+ mdl = s.model()
911
+ base = s.construction().get()
912
+ space = s.space().get()
913
+ type = s.surfaceType()
914
+ bnd = s.outsideBoundaryCondition().lower()
915
+
916
+ ground = True if s.isGroundSurface() else False
917
+ exterior = True if bnd == "outdoors" else False
918
+
919
+ if space.defaultConstructionSet():
920
+ set = space.defaultConstructionSet().get()
921
+
922
+ if holdsConstruction(set, base, ground, exterior, type): return set
923
+
924
+ if space.spaceType():
925
+ spacetype = space.spaceType().get()
926
+
927
+ if spacetype.defaultConstructionSet():
928
+ set = spacetype.defaultConstructionSet().get()
929
+
930
+ if holdsConstruction(set, base, ground, exterior, type):
931
+ return set
932
+
933
+ if space.buildingStory():
934
+ story = space.buildingStory().get()
935
+
936
+ if story.defaultConstructionSet():
937
+ set = story.defaultConstructionSet().get()
938
+
939
+ if holdsConstruction(set, base, ground, exterior, type):
940
+ return set
941
+
942
+
943
+ building = mdl.getBuilding()
944
+
945
+ if building.defaultConstructionSet():
946
+ set = building.defaultConstructionSet().get()
947
+
948
+ if holdsConstruction(set, base, ground, exterior, type):
949
+ return set
950
+
951
+ return None
952
+
953
+
954
+ def are_standardOpaqueLayers(lc=None) -> bool:
955
+ """Validates if every material in a layered construction is standard/opaque.
956
+
957
+ Args:
958
+ lc (openstudio.model.LayeredConstruction):
959
+ an OpenStudio layered construction
960
+
961
+ Returns:
962
+ True: If all layers are valid (standard & opaque).
963
+ False: If invalid inputs (see logs).
964
+
965
+ """
966
+ mth = "osut.are_standardOpaqueLayers"
967
+ cl = openstudio.model.LayeredConstruction
968
+
969
+ if not isinstance(lc, cl):
970
+ return oslg.mismatch("lc", lc, cl, mth, CN.DBG, 0.0)
971
+
972
+ for m in lc.layers():
973
+ if not m.to_StandardOpaqueMaterial(): return False
974
+
975
+ return True
976
+
977
+
978
+ def thickness(lc=None) -> float:
979
+ """Returns total (standard opaque) layered construction thickness (m).
980
+
981
+ Args:
982
+ lc (openstudio.model.LayeredConstruction):
983
+ an OpenStudio layered construction
984
+
985
+ Returns:
986
+ float: A standard opaque construction thickness.
987
+ 0.0: If invalid inputs (see logs).
988
+
989
+ """
990
+ mth = "osut.thickness"
991
+ cl = openstudio.model.LayeredConstruction
992
+ d = 0.0
993
+
994
+ if not isinstance(lc, cl):
995
+ return oslg.mismatch("lc", lc, cl, mth, CN.DBG, 0.0)
996
+ if not are_standardOpaqueLayers(lc):
997
+ oslg.log(CN.ERR, "holding non-StandardOpaqueMaterial(s) %s" % mth)
998
+ return d
999
+
1000
+ for m in lc.layers(): d += m.thickness()
1001
+
1002
+ return d
1003
+
1004
+
1005
+ def glazingAirFilmRSi(usi=5.85) -> float:
1006
+ """Returns total air film resistance of a fenestrated construction (m2•K/W).
1007
+
1008
+ Args:
1009
+ usi (float):
1010
+ A fenestrated construction's U-factor (W/m2•K).
1011
+
1012
+ Returns:
1013
+ float: Total air film resistances.
1014
+ 0.1216: If invalid input (see logs).
1015
+
1016
+ """
1017
+ # The sum of thermal resistances of calculated exterior and interior film
1018
+ # coefficients under standard winter conditions are taken from:
1019
+ #
1020
+ # https://bigladdersoftware.com/epx/docs/9-6/engineering-reference/
1021
+ # window-calculation-module.html#simple-window-model
1022
+ #
1023
+ # These remain acceptable approximations for flat windows, yet likely
1024
+ # unsuitable for subsurfaces with curved or projecting shapes like domed
1025
+ # skylights. The solution here is considered an adequate fix for reporting,
1026
+ # awaiting eventual OpenStudio (and EnergyPlus) upgrades to report NFRC 100
1027
+ # (or ISO) air film resistances under standard winter conditions.
1028
+ #
1029
+ # For U-factors above 8.0 W/m2•K (or invalid input), the function returns
1030
+ # 0.1216 m2•K/W, which corresponds to a construction with a single glass
1031
+ # layer thickness of 2mm & k = ~0.6 W/m.K.
1032
+ #
1033
+ # The EnergyPlus Engineering calculations were designed for vertical
1034
+ # windows, not for horizontal, slanted or domed surfaces - use with caution.
1035
+ mth = "osut.glazingAirFilmRSi"
1036
+ val = 0.1216
1037
+
1038
+ try:
1039
+ usi = float(usi)
1040
+ except:
1041
+ return oslg.mismatch("usi", usi, float, mth, CN.DBG, val)
1042
+
1043
+ if usi > 8.0:
1044
+ return oslg.invalid("usi", mth, 1, CN.WRN, val)
1045
+ elif usi < 0:
1046
+ return oslg.negative("usi", mth, CN.WRN, val)
1047
+ elif abs(usi) < CN.TOL:
1048
+ return oslg.zero("usi", mth, CN.WRN, val)
1049
+
1050
+ rsi = 1 / (0.025342 * usi + 29.163853) # exterior film, next interior film
1051
+
1052
+ if usi < 5.85:
1053
+ return rsi + 1 / (0.359073 * math.log(usi) + 6.949915)
1054
+
1055
+ return rsi + 1 / (1.788041 * usi - 2.886625)
1056
+
1057
+
1058
+ def rsi(lc=None, film=0.0, t=0.0) -> float:
1059
+ """Returns a construction's 'standard calc' thermal resistance (m2•K/W),
1060
+ which includes air film resistances. It excludes insulating effects of
1061
+ shades, screens, etc. in the case of fenestrated constructions. Adapted
1062
+ from BTAP's 'Material' Module "get_conductance" (P. Lopez).
1063
+
1064
+ Args:
1065
+ lc (openstudio.model.LayeredConstruction):
1066
+ an OpenStudio layered construction
1067
+ film (float):
1068
+ thermal resistance of surface air films (m2•K/W)
1069
+ t (float):
1070
+ gas temperature (°C) (optional)
1071
+
1072
+ Returns:
1073
+ float: A layered construction's thermal resistance.
1074
+ 0.0: If invalid input (see logs).
1075
+
1076
+ """
1077
+ mth = "osut.rsi"
1078
+ cl = openstudio.model.LayeredConstruction
1079
+
1080
+ if not isinstance(lc, cl):
1081
+ return oslg.mismatch("lc", lc, cl, mth, CN.DBG, 0.0)
1082
+
1083
+ try:
1084
+ film = float(film)
1085
+ except:
1086
+ return oslg.mismatch("film", film, float, mth, CN.DBG, 0.0)
1087
+
1088
+ try:
1089
+ t = float(t)
1090
+ except:
1091
+ return oslg.mismatch("temp K", t, float, mth, CN.DBG, 0.0)
1092
+
1093
+ t += 273.0 # °C to K
1094
+
1095
+ if t < 0:
1096
+ return oslg.negative("temp K", mth, CN.ERR, 0.0)
1097
+ if film < 0:
1098
+ return oslg.negative("film", mth, CN.ERR, 0.0)
1099
+
1100
+ rsi = film
1101
+
1102
+ for m in lc.layers():
1103
+ if m.to_SimpleGlazing():
1104
+ return 1 / m.to_SimpleGlazing().get().uFactor()
1105
+ elif m.to_StandardGlazing():
1106
+ rsi += m.to_StandardGlazing().get().thermalResistance()
1107
+ elif m.to_RefractionExtinctionGlazing():
1108
+ rsi += m.to_RefractionExtinctionGlazing().get().thermalResistance()
1109
+ elif m.to_Gas():
1110
+ rsi += m.to_Gas().get().getThermalResistance(t)
1111
+ elif m.to_GasMixture():
1112
+ rsi += m.to_GasMixture().get().getThermalResistance(t)
1113
+
1114
+ # Opaque materials next.
1115
+ if m.to_StandardOpaqueMaterial():
1116
+ rsi += m.to_StandardOpaqueMaterial().get().thermalResistance()
1117
+ elif m.to_MasslessOpaqueMaterial():
1118
+ rsi += m.to_MasslessOpaqueMaterial()
1119
+ elif m.to_RoofVegetation():
1120
+ rsi += m.to_RoofVegetation().get().thermalResistance()
1121
+ elif m.to_AirGap():
1122
+ rsi += m.to_AirGap().get().thermalResistance()
1123
+
1124
+ return rsi
1125
+
1126
+
1127
+ def insulatingLayer(lc=None) -> dict:
1128
+ """Identifies a layered construction's (opaque) insulating layer.
1129
+
1130
+ Args:
1131
+ lc (openStudio.model.LayeredConstruction):
1132
+ an OpenStudio layered construction
1133
+
1134
+ Returns:
1135
+ An insulating-layer dictionary:
1136
+ - "index" (int): construction's insulating layer index [0, n layers)
1137
+ - "type" (str): layer material type ("standard" or "massless")
1138
+ - "r" (float): material thermal resistance in m2•K/W.
1139
+ If unsuccessful, dictionary is voided as follows (see logs):
1140
+ "index": None
1141
+ "type": None
1142
+ "r": 0.0
1143
+
1144
+ """
1145
+ mth = "osut.insulatingLayer"
1146
+ cl = openstudio.model.LayeredConstruction
1147
+ res = dict(index=None, type=None, r=0.0)
1148
+ i = 0 # iterator
1149
+
1150
+ if not isinstance(lc, cl):
1151
+ return oslg.mismatch("lc", lc, cl, mth, CN.DBG, res)
1152
+
1153
+ for m in lc.layers():
1154
+ if m.to_MasslessOpaqueMaterial():
1155
+ m = m.to_MasslessOpaqueMaterial().get()
1156
+
1157
+ if m.thermalResistance() < 0.001 or m.thermalResistance() < res["r"]:
1158
+ i += 1
1159
+ continue
1160
+ else:
1161
+ res["r" ] = m.thermalResistance()
1162
+ res["index"] = i
1163
+ res["type" ] = "massless"
1164
+
1165
+ if m.to_StandardOpaqueMaterial():
1166
+ m = m.to_StandardOpaqueMaterial().get()
1167
+ k = m.thermalConductivity()
1168
+ d = m.thickness()
1169
+
1170
+ if (d < 0.003) or (k > 3.0) or (d / k < res["r"]):
1171
+ i += 1
1172
+ continue
1173
+ else:
1174
+ res["r" ] = d / k
1175
+ res["index"] = i
1176
+ res["type" ] = "standard"
1177
+
1178
+ i += 1
1179
+
1180
+ return res
1181
+
1182
+
1183
+ def is_spandrel(s=None) -> bool:
1184
+ """Validates whether opaque surface can be considered as a curtain wall
1185
+ (or similar technology) spandrel, regardless of construction layers, by
1186
+ looking up AdditionalProperties or its identifier.
1187
+
1188
+ Args:
1189
+ s (openstudio.model.Surface):
1190
+ An opaque surface.
1191
+
1192
+ Returns:
1193
+ bool: Whether surface can be considered 'spandrel'.
1194
+ False: If invalid input (see logs).
1195
+ """
1196
+ mth = "osut.is_spandrel"
1197
+ cl = openstudio.model.Surface
1198
+
1199
+ if not isinstance(s, cl):
1200
+ return oslg.mismatch("surface", s, cl, mth, CN.DBG, False)
1201
+
1202
+ # Prioritize AdditionalProperties route.
1203
+ if s.additionalProperties().hasFeature("spandrel"):
1204
+ val = s.additionalProperties().getFeatureAsBoolean("spandrel")
1205
+
1206
+ if not val:
1207
+ return oslg.invalid("spandrel", mth, 1, CN.ERR, False)
1208
+
1209
+ val = val.get()
1210
+
1211
+ if not isinstance(val, bool):
1212
+ return invalid("spandrel bool", mth, 1, CN.ERR, False)
1213
+
1214
+ return val
1215
+
1216
+ # Fallback: check for 'spandrel' in surface name.
1217
+ return "spandrel" in s.nameString().lower()
1218
+
1219
+
1220
+ def is_fenestration(s=None) -> bool:
1221
+ """Validates whether a sub surface is fenestrated.
1222
+
1223
+ Args:
1224
+ s (openstudio.model.SubSurface):
1225
+ An OpenStudio sub surface.
1226
+
1227
+ Returns:
1228
+ bool: Whether subsurface can be considered 'fenestrated'.
1229
+ False: If invalid input (see logs).
1230
+
1231
+ """
1232
+ mth = "osut.is_fenestration"
1233
+ cl = openstudio.model.SubSurface
1234
+
1235
+ if not isinstance(s, cl):
1236
+ return oslg.mismatch("subsurface", s, cl, mth, CN.DBG, False)
1237
+
1238
+ # OpenStudio::Model::SubSurface.validSubSurfaceTypeValues
1239
+ # "FixedWindow" : fenestration
1240
+ # "OperableWindow" : fenestration
1241
+ # "Door"
1242
+ # "GlassDoor" : fenestration
1243
+ # "OverheadDoor"
1244
+ # "Skylight" : fenestration
1245
+ # "TubularDaylightDome" : fenestration
1246
+ # "TubularDaylightDiffuser" : fenestration
1247
+ if s.subSurfaceType().lower() in ["door", "overheaddoor"]: return False
1248
+
1249
+ return True
1250
+
1251
+
1252
+ def has_airLoopsHVAC(model=None) -> bool:
1253
+ """Validates if model has zones with HVAC air loops.
1254
+
1255
+ Args:
1256
+ model (openstudio.model.Model):
1257
+ An OpenStudio model.
1258
+
1259
+ Returns:
1260
+ bool: Whether model has HVAC air loops.
1261
+ False: If invalid input (see logs).
1262
+ """
1263
+ mth = "osut.has_airLoopsHVAC"
1264
+ cl = openstudio.model.Model
1265
+
1266
+ if not isinstance(model, cl):
1267
+ return oslg.mismatch("model", model, cl, mth, CN.DBG, False)
1268
+
1269
+ for zone in model.getThermalZones():
1270
+ if zone.canBePlenum(): continue
1271
+ if zone.airLoopHVACs() or zone.isPlenum(): return True
1272
+
1273
+ return False
1274
+
1275
+
1276
+ def scheduleRulesetMinMax(sched=None) -> dict:
1277
+ """Returns MIN/MAX values of a schedule (ruleset).
1278
+
1279
+ Args:
1280
+ sched (openstudio.model.ScheduleRuleset):
1281
+ A schedule.
1282
+
1283
+ Returns:
1284
+ dict:
1285
+ - "min" (float): min temperature. (None if invalid inputs - see logs).
1286
+ - "max" (float): max temperature. (None if invalid inputs - see logs).
1287
+ """
1288
+ # Largely inspired from David Goldwasser's
1289
+ # "schedule_ruleset_annual_min_max_value":
1290
+ #
1291
+ # github.com/NREL/openstudio-standards/blob/
1292
+ # 99cf713750661fe7d2082739f251269c2dfd9140/lib/openstudio-standards/
1293
+ # standards/Standards.ScheduleRuleset.rb#L124
1294
+ mth = "osut.scheduleRulesetMinMax"
1295
+ cl = openstudio.model.ScheduleRuleset
1296
+ res = dict(min=None, max=None)
1297
+
1298
+ if not isinstance(sched, cl):
1299
+ return oslg.mismatch("sched", sched, cl, mth, CN.DBG, res)
1300
+
1301
+ values = list(sched.defaultDaySchedule().values())
1302
+
1303
+ for rule in sched.scheduleRules(): values += rule.daySchedule().values()
1304
+
1305
+ res["min"] = min(values)
1306
+ res["max"] = max(values)
1307
+
1308
+ try:
1309
+ res["min"] = float(res["min"])
1310
+ except:
1311
+ res["min"] = None
1312
+
1313
+ try:
1314
+ res["max"] = float(res["max"])
1315
+ except:
1316
+ res["max"] = None
1317
+
1318
+ return res
1319
+
1320
+
1321
+ def scheduleConstantMinMax(sched=None) -> dict:
1322
+ """Returns MIN/MAX values of a schedule (constant).
1323
+
1324
+ Args:
1325
+ sched (openstudio.model.ScheduleConstant):
1326
+ A schedule.
1327
+
1328
+ Returns:
1329
+ dict:
1330
+ - "min" (float): min temperature. (None if invalid inputs - see logs).
1331
+ - "max" (float): max temperature. (None if invalid inputs - see logs).
1332
+ """
1333
+ # Largely inspired from David Goldwasser's
1334
+ # "schedule_constant_annual_min_max_value":
1335
+ #
1336
+ # github.com/NREL/openstudio-standards/blob/
1337
+ # 99cf713750661fe7d2082739f251269c2dfd9140/lib/openstudio-standards/
1338
+ # standards/Standards.ScheduleConstant.rb#L21
1339
+ mth = "osut.scheduleConstantMinMax"
1340
+ cl = openstudio.model.ScheduleConstant
1341
+ res = dict(min=None, max=None)
1342
+
1343
+ if not isinstance(sched, cl):
1344
+ return oslg.mismatch("sched", sched, cl, mth, CN.DBG, res)
1345
+
1346
+ try:
1347
+ value = float(sched.value())
1348
+ except:
1349
+ return None
1350
+
1351
+ res["min"] = value
1352
+ res["max"] = value
1353
+
1354
+ return res
1355
+
1356
+
1357
+ def scheduleCompactMinMax(sched=None) -> dict:
1358
+ """Returns MIN/MAX values of a schedule (compact).
1359
+
1360
+ Args:
1361
+ sched (openstudio.model.ScheduleCompact):
1362
+ A schedule.
1363
+
1364
+ Returns:
1365
+ dict:
1366
+ - "min" (float): min temperature. (None if invalid inputs - see logs).
1367
+ - "max" (float): max temperature. (None if invalid inputs - see logs).
1368
+ """
1369
+ # Largely inspired from Andrew Parker's
1370
+ # "schedule_compact_annual_min_max_value":
1371
+ #
1372
+ # github.com/NREL/openstudio-standards/blob/
1373
+ # 99cf713750661fe7d2082739f251269c2dfd9140/lib/openstudio-standards/
1374
+ # standards/Standards.ScheduleCompact.rb#L8
1375
+ mth = "osut.scheduleCompactMinMax"
1376
+ cl = openstudio.model.ScheduleCompact
1377
+ vals = []
1378
+ prev = ""
1379
+ res = dict(min=None, max=None)
1380
+
1381
+ if not isinstance(sched, cl):
1382
+ return oslg.mismatch("sched", sched, cl, mth, CN.DBG, res)
1383
+
1384
+ for eg in sched.extensibleGroups():
1385
+ if "until" in prev:
1386
+ if eg.getDouble(0): vals.append(eg.getDouble(0).get())
1387
+
1388
+ str = eg.getString(0)
1389
+
1390
+ if str: prev = str.get().lower()
1391
+
1392
+ if not vals:
1393
+ return oslg.empty("compact sched values", mth, CN.WRN, res)
1394
+
1395
+ res["min"] = min(vals)
1396
+ res["max"] = max(vals)
1397
+
1398
+ try:
1399
+ res["min"] = float(res["min"])
1400
+ except:
1401
+ res["min"] = None
1402
+
1403
+ try:
1404
+ res["max"] = float(res["max"])
1405
+ except:
1406
+ res["max"] = None
1407
+
1408
+ return res
1409
+
1410
+
1411
+ def scheduleIntervalMinMax(sched=None) -> dict:
1412
+ """Returns MIN/MAX values of a schedule (interval).
1413
+
1414
+ Args:
1415
+ sched (openstudio.model.ScheduleInterval):
1416
+ A schedule.
1417
+
1418
+ Returns:
1419
+ dict:
1420
+ - "min" (float): min temperature. (None if invalid inputs - see logs).
1421
+ - "max" (float): max temperature. (None if invalid inputs - see logs).
1422
+ """
1423
+ mth = "osut.scheduleCompactMinMax"
1424
+ cl = openstudio.model.ScheduleInterval
1425
+ vals = []
1426
+ res = dict(min=None, max=None)
1427
+
1428
+ if not isinstance(sched, cl):
1429
+ return oslg.mismatch("sched", sched, cl, mth, CN.DBG, res)
1430
+
1431
+ vals = sched.timeSeries().values()
1432
+
1433
+ res["min"] = min(values)
1434
+ res["max"] = max(values)
1435
+
1436
+ try:
1437
+ res["min"] = float(res["min"])
1438
+ except:
1439
+ res["min"] = None
1440
+
1441
+ try:
1442
+ res["max"] = float(res["max"])
1443
+ except:
1444
+ res["max"] = None
1445
+
1446
+ return res
1447
+
1448
+
1449
+ def maxHeatScheduledSetpoint(zone=None) -> dict:
1450
+ """Returns MAX zone heating temperature schedule setpoint [°C] and
1451
+ whether zone has an active dual setpoint thermostat.
1452
+
1453
+ Args:
1454
+ zone (openstudio.model.ThermalZone):
1455
+ An OpenStudio thermal zone.
1456
+
1457
+ Returns:
1458
+ dict:
1459
+ - spt (float): MAX heating setpoint (None if invalid inputs - see logs).
1460
+ - dual (bool): dual setpoint? (False if invalid inputs - see logs).
1461
+ """
1462
+ # Largely inspired from Parker & Marrec's "thermal_zone_heated?" procedure.
1463
+ # The solution here is a tad more relaxed to encompass SEMIHEATED zones as
1464
+ # per Canadian NECB criteria (basically any space with at least 10 W/m2 of
1465
+ # installed heating equipement, i.e. below freezing in Canada).
1466
+ #
1467
+ # github.com/NREL/openstudio-standards/blob/
1468
+ # 58964222d25783e9da4ae292e375fb0d5c902aa5/lib/openstudio-standards/
1469
+ # standards/Standards.ThermalZone.rb#L910
1470
+ mth = "osut.maxHeatScheduledSetpoint"
1471
+ cl = openstudio.model.ThermalZone
1472
+ res = dict(spt=None, dual=False)
1473
+
1474
+ if not isinstance(zone, cl):
1475
+ return oslg.mismatch("zone", zone, cl, mth, CN.DBG, res)
1476
+
1477
+ # Zone radiant heating? Get schedule from radiant system.
1478
+ for equip in zone.equipment():
1479
+ sched = None
1480
+
1481
+ if equip.to_ZoneHVACHighTemperatureRadiant():
1482
+ equip = equip.to_ZoneHVACHighTemperatureRadiant().get()
1483
+
1484
+ if equip.heatingSetpointTemperatureSchedule():
1485
+ sched = equip.heatingSetpointTemperatureSchedule().get()
1486
+
1487
+ if equip.to_ZoneHVACLowTemperatureRadiantElectric():
1488
+ equip = equip.to_ZoneHVACLowTemperatureRadiantElectric().get()
1489
+
1490
+ sched = equip.heatingSetpointTemperatureSchedule()
1491
+
1492
+ if equip.to_ZoneHVACLowTempRadiantConstFlow():
1493
+ equip = equip.to_ZoneHVACLowTempRadiantConstFlow().get()
1494
+ coil = equip.heatingCoil()
1495
+
1496
+ if coil.to_CoilHeatingLowTempRadiantConstFlow():
1497
+ coil = coil.to_CoilHeatingLowTempRadiantConstFlow().get()
1498
+
1499
+ if coil.heatingHighControlTemperatureSchedule():
1500
+ sched = c.heatingHighControlTemperatureSchedule().get()
1501
+
1502
+ if equip.to_ZoneHVACLowTempRadiantVarFlow():
1503
+ equip = equip.to_ZoneHVACLowTempRadiantVarFlow().get()
1504
+ coil = equip.heatingCoil()
1505
+
1506
+ if coil.to_CoilHeatingLowTempRadiantVarFlow():
1507
+ coil = coil.to_CoilHeatingLowTempRadiantVarFlow().get()
1508
+
1509
+ if coil.heatingControlTemperatureSchedule():
1510
+ sched = coil.heatingControlTemperatureSchedule().get()
1511
+
1512
+ if not sched: continue
1513
+
1514
+ if sched.to_ScheduleRuleset():
1515
+ sched = sched.to_ScheduleRuleset().get()
1516
+ maximum = scheduleRulesetMinMax(sched)["max"]
1517
+
1518
+ if maximum:
1519
+ if not res["spt"] or res["spt"] < maximum:
1520
+ res["spt"] = maximum
1521
+
1522
+ dd = sched.winterDesignDaySchedule()
1523
+
1524
+ if dd.values():
1525
+ if not res["spt"] or res["spt"] < max(dd.values()):
1526
+ res["spt"] = max(dd.values())
1527
+
1528
+ if sched.to_ScheduleConstant():
1529
+ sched = sched.to_ScheduleConstant().get()
1530
+ maximum = scheduleConstantMinMax(sched)["max"]
1531
+
1532
+ if maximum:
1533
+ if not res["spt"] or res["spt"] < maximum:
1534
+ res["spt"] = maximum
1535
+
1536
+ if sched.to_ScheduleCompact():
1537
+ sched = sched.to_ScheduleCompact().get()
1538
+ maximum = scheduleCompactMinMax(sched)["max"]
1539
+
1540
+ if maximum:
1541
+ if not res["spt"] or res["spt"] < maximum:
1542
+ res["spt"] = maximum
1543
+
1544
+ if sched.to_ScheduleInterval():
1545
+ sched = sched.to_ScheduleInterval().get()
1546
+ maximum = scheduleIntervalMinMax(sched)["max"]
1547
+
1548
+ if maximum:
1549
+ if not res["spt"] or res["spt"] < maximum:
1550
+ res["spt"] = maximum
1551
+
1552
+ if not zone.thermostat(): return res
1553
+
1554
+ tstat = zone.thermostat().get()
1555
+ res["spt"] = None
1556
+
1557
+ if (tstat.to_ThermostatSetpointDualSetpoint() or
1558
+ tstat.to_ZoneControlThermostatStagedDualSetpoint()):
1559
+
1560
+ if tstat.to_ThermostatSetpointDualSetpoint():
1561
+ tstat = tstat.to_ThermostatSetpointDualSetpoint().get()
1562
+ else:
1563
+ tstat = tstat.to_ZoneControlThermostatStagedDualSetpoint().get()
1564
+
1565
+ if tstat.heatingSetpointTemperatureSchedule():
1566
+ res["dual"] = True
1567
+ sched = tstat.heatingSetpointTemperatureSchedule().get()
1568
+
1569
+ if sched.to_ScheduleRuleset():
1570
+ sched = sched.to_ScheduleRuleset().get()
1571
+ maximum = scheduleRulesetMinMax(sched)["max"]
1572
+
1573
+ if maximum:
1574
+ if not res["spt"] or res["spt"] < maximum:
1575
+ res["spt"] = maximum
1576
+
1577
+ dd = sched.winterDesignDaySchedule()
1578
+
1579
+ if dd.values():
1580
+ if not res["spt"] or res["spt"] < max(dd.values()):
1581
+ res["spt"] = max(dd.values())
1582
+
1583
+ if sched.to_ScheduleConstant():
1584
+ sched = sched.to_ScheduleConstant().get()
1585
+ maximum = scheduleConstantMinMax(sched)["max"]
1586
+
1587
+ if maximum:
1588
+ if not res["spt"] or res["spt"] < maximum:
1589
+ res["spt"] = maximum
1590
+
1591
+ if sched.to_ScheduleCompact():
1592
+ sched = sched.to_ScheduleCompact().get()
1593
+ maximum = scheduleCompactMinMax(sched)["max"]
1594
+
1595
+ if maximum:
1596
+ if not res["spt"] or res["spt"] < maximum:
1597
+ res["spt"] = maximum
1598
+
1599
+ if sched.to_ScheduleInterval():
1600
+ sched = sched.to_ScheduleInterval().get()
1601
+ maximum = scheduleIntervalMinMax(sched)["max"]
1602
+
1603
+ if maximum:
1604
+ if not res["spt"] or res["spt"] < maximum:
1605
+ res["spt"] = maximum
1606
+
1607
+ if sched.to_ScheduleYear():
1608
+ sched = sched.to_ScheduleYear().get()
1609
+
1610
+ for week in sched.getScheduleWeeks():
1611
+ if not week.winterDesignDaySchedule():
1612
+ dd = week.winterDesignDaySchedule().get()
1613
+
1614
+ if dd.values():
1615
+ if not res["spt"] or res["spt"] < max(dd.values()):
1616
+ res["spt"] = max(dd.values())
1617
+ return res
1618
+
1619
+
1620
+ def has_heatingTemperatureSetpoints(model=None):
1621
+ """Confirms if model has zones with valid heating temperature setpoints.
1622
+
1623
+ Args:
1624
+ model (openstudio.model.Model):
1625
+ An OpenStudio model.
1626
+
1627
+ Returns:
1628
+ bool: Whether model holds valid heating temperature setpoints.
1629
+ False: If invalid inputs (see logs).
1630
+ """
1631
+ mth = "osut.has_heatingTemperatureSetpoints"
1632
+ cl = openstudio.model.Model
1633
+
1634
+ if not isinstance(model, cl):
1635
+ return oslg.mismatch("model", model, cl, mth, CN.DBG, False)
1636
+
1637
+ for zone in model.getThermalZones():
1638
+ if maxHeatScheduledSetpoint(zone)["spt"]: return True
1639
+
1640
+ return False
1641
+
1642
+
1643
+ def minCoolScheduledSetpoint(zone=None):
1644
+ """Returns MIN zone cooling temperature schedule setpoint [°C] and
1645
+ whether zone has an active dual setpoint thermostat.
1646
+
1647
+ Args:
1648
+ zone (openstudio.model.ThermalZone):
1649
+ An OpenStudio thermal zone.
1650
+
1651
+ Returns:
1652
+ dict:
1653
+ - spt (float): MIN cooling setpoint (None if invalid inputs - see logs).
1654
+ - dual (bool): dual setpoint? (False if invalid inputs - see logs).
1655
+ """
1656
+ # Largely inspired from Parker & Marrec's "thermal_zone_cooled?" procedure.
1657
+ #
1658
+ # github.com/NREL/openstudio-standards/blob/
1659
+ # 99cf713750661fe7d2082739f251269c2dfd9140/lib/openstudio-standards/
1660
+ # standards/Standards.ThermalZone.rb#L1058
1661
+ mth = "osut.minCoolScheduledSetpoint"
1662
+ cl = openstudio.model.ThermalZone
1663
+ res = dict(spt=None, dual=False)
1664
+
1665
+ if not isinstance(zone, cl):
1666
+ return oslg.mismatch("zone", zone, cl, mth, CN.DBG, res)
1667
+
1668
+ # Zone radiant cooling? Get schedule from radiant system.
1669
+ for equip in zone.equipment():
1670
+ sched = None
1671
+
1672
+ if equip.to_ZoneHVACLowTempRadiantConstFlow():
1673
+ equip = equip.to_ZoneHVACLowTempRadiantConstFlow().get()
1674
+ coil = equip.coolingCoil()
1675
+
1676
+ if coil.to_CoilCoolingLowTempRadiantConstFlow():
1677
+ coil = coil.to_CoilCoolingLowTempRadiantConstFlow().get()
1678
+
1679
+ if coil.coolingLowControlTemperatureSchedule():
1680
+ sched = coil.coolingLowControlTemperatureSchedule().get()
1681
+
1682
+ if equip.to_ZoneHVACLowTempRadiantVarFlow():
1683
+ equip = equip.to_ZoneHVACLowTempRadiantVarFlow().get()
1684
+ coil = equip.coolingCoil()
1685
+
1686
+ if coil.to_CoilCoolingLowTempRadiantVarFlow():
1687
+ coil = coil.to_CoilCoolingLowTempRadiantVarFlow().get()
1688
+
1689
+ if coil.coolingControlTemperatureSchedule():
1690
+ sched = coil.coolingControlTemperatureSchedule().get()
1691
+
1692
+ if not sched: continue
1693
+
1694
+ if sched.to_ScheduleRuleset():
1695
+ sched = sched.to_ScheduleRuleset().get()
1696
+ minimum = scheduleRulesetMinMax(sched)["min"]
1697
+
1698
+ if minimum:
1699
+ if not res["spt"] or res["spt"] > minimum:
1700
+ res["spt"] = minimum
1701
+
1702
+ dd = sched.summerDesignDaySchedule()
1703
+
1704
+ if dd.values():
1705
+ if not res["spt"] or res["spt"] > min(dd.values()):
1706
+ res["spt"] = min(dd.values())
1707
+
1708
+ if sched.to_ScheduleConstant():
1709
+ sched = sched.to_ScheduleConstant().get()
1710
+ minimum = scheduleConstantMinMax(sched)["min"]
1711
+
1712
+ if minimum:
1713
+ if not res["spt"] or res["spt"] > minimum:
1714
+ res["spt"] = minimum
1715
+
1716
+ if sched.to_ScheduleCompact():
1717
+ sched = sched.to_ScheduleCompact().get()
1718
+ minimum = scheduleCompactMinMax(sched)["min"]
1719
+
1720
+ if minimum:
1721
+ if not res["spt"] or res["spt"] > minimum:
1722
+ res["spt"] = minimum
1723
+
1724
+ if sched.to_ScheduleInterval():
1725
+ sched = sched.to_ScheduleInterval().get()
1726
+ minimum = scheduleIntervalMinMax(sched)["min"]
1727
+
1728
+ if minimum:
1729
+ if not res["spt"] or res["spt"] > minimum:
1730
+ res["spt"] = minimum
1731
+
1732
+ if not zone.thermostat(): return res
1733
+
1734
+ tstat = zone.thermostat().get()
1735
+ res["spt"] = None
1736
+
1737
+ if (tstat.to_ThermostatSetpointDualSetpoint() or
1738
+ tstat.to_ZoneControlThermostatStagedDualSetpoint()):
1739
+
1740
+ if tstat.to_ThermostatSetpointDualSetpoint():
1741
+ tstat = tstat.to_ThermostatSetpointDualSetpoint().get()
1742
+ else:
1743
+ tstat = tstat.to_ZoneControlThermostatStagedDualSetpoint().get()
1744
+
1745
+ if tstat.coolingSetpointTemperatureSchedule():
1746
+ res["dual"] = True
1747
+ sched = tstat.coolingSetpointTemperatureSchedule().get()
1748
+
1749
+ if sched.to_ScheduleRuleset():
1750
+ sched = sched.to_ScheduleRuleset().get()
1751
+
1752
+ minimum = scheduleRulesetMinMax(sched)["min"]
1753
+
1754
+ if minimum:
1755
+ if not res["spt"] or res["spt"] > minimum:
1756
+ res["spt"] = minimum
1757
+
1758
+ dd = sched.summerDesignDaySchedule()
1759
+
1760
+ if dd.values():
1761
+ if not res["spt"] or res["spt"] > min(dd.values()):
1762
+ res["spt"] = min(dd.values())
1763
+
1764
+ if sched.to_ScheduleConstant():
1765
+ sched = sched.to_ScheduleConstant().get()
1766
+ minimum = scheduleConstantMinMax(sched)[:min]
1767
+
1768
+ if minimum:
1769
+ if not res["spt"] or res["spt"] > minimum:
1770
+ res["spt"] = minimum
1771
+
1772
+ if sched.to_ScheduleCompact():
1773
+ sched = sched.to_ScheduleCompact().get()
1774
+ minimum = scheduleCompactMinMax(sched)["min"]
1775
+
1776
+ if minimum:
1777
+ if not res["spt"] or res["spt"] > minimum:
1778
+ res["spt"] = minimum
1779
+
1780
+ if sched.to_ScheduleInterval():
1781
+ sched = sched.to_ScheduleInterval().get()
1782
+ minimum = scheduleIntervalMinMax(sched)["min"]
1783
+
1784
+ if minimum:
1785
+ if not res["spt"] or res["spt"] > minimum:
1786
+ res["spt"] = minimum
1787
+
1788
+ if sched.to_ScheduleYear():
1789
+ sched = sched.to_ScheduleYear().get()
1790
+
1791
+ for week in sched.getScheduleWeeks():
1792
+ if not week.summerDesignDaySchedule():
1793
+ dd = week.summerDesignDaySchedule().get()
1794
+
1795
+ if dd.values():
1796
+ if not res["spt"] or res["spt"] < min(dd.values()):
1797
+ res["spt"] = min(dd.values())
1798
+
1799
+ return res
1800
+
1801
+
1802
+ def has_coolingTemperatureSetpoints(model=None):
1803
+ """Confirms if model has zones with valid cooling temperature setpoints.
1804
+
1805
+ Args:
1806
+ model (openstudio.model.Model):
1807
+ An OpenStudio model.
1808
+
1809
+ Returns:
1810
+ bool: Whether model holds valid cooling temperature setpoints.
1811
+ False: If invalid inputs (see logs).
1812
+ """
1813
+ mth = "osut.has_coolingTemperatureSetpoints"
1814
+ cl = openstudio.model.Model
1815
+
1816
+ if not isinstance(model, cl):
1817
+ return oslg.mismatch("model", model, cl, mth, CN.DBG, False)
1818
+
1819
+ for zone in model.getThermalZones():
1820
+ if minCoolScheduledSetpoint(zone)["spt"]: return True
1821
+
1822
+ return False
1823
+
1824
+
1825
+ def is_vestibule(space=None):
1826
+ """Validates whether space is a vestibule.
1827
+
1828
+ Args:
1829
+ space ():
1830
+ An OpenStudio space.
1831
+
1832
+ Returns:
1833
+ bool: Whether space is considered a vestibule.
1834
+ False: If invalid input (see logs).
1835
+ """
1836
+ # INFO: OpenStudio-Standards' "thermal_zone_vestibule" criteria:
1837
+ # - zones less than 200ft2; AND
1838
+ # - having infiltration using Design Flow Rate
1839
+ #
1840
+ # github.com/NREL/openstudio-standards/blob/
1841
+ # 86bcd026a20001d903cc613bed6d63e94b14b142/lib/openstudio-standards/
1842
+ # standards/Standards.ThermalZone.rb#L1264
1843
+ #
1844
+ # This (unused) OpenStudio-Standards method likely needs revision; it
1845
+ # returns "false" if the thermal zone area were less than 200ft2. Not sure
1846
+ # which edition of 90.1 relies on a 200ft2 threshold (2010?); 90.1 2016
1847
+ # doesn't. Yet even fixed, the method would nonetheless misidentify as
1848
+ # "vestibule" a small space along an exterior wall, such as a semiheated
1849
+ # storage space.
1850
+ #
1851
+ # The code below is intended as a simple short-term solution, basically
1852
+ # relying on AdditionalProperties, or (if missing) a "vestibule" substring
1853
+ # within a space's spaceType name (or the latter's standardsSpaceType).
1854
+ #
1855
+ # Alternatively, some future method could infer its status as a vestibule
1856
+ # based on a few basic features (common to all vintages):
1857
+ # - 1x+ outdoor-facing wall(s) holding 1x+ door(s)
1858
+ # - adjacent to 1x+ 'occupied' conditioned space(s)
1859
+ # - ideally, 1x+ door(s) between vestibule and 1x+ such adjacent space(s)
1860
+ #
1861
+ # An additional method parameter (i.e. std = "necb") could be added to
1862
+ # ensure supplementary Standard-specific checks, e.g. maximum floor area,
1863
+ # minimum distance between doors.
1864
+ #
1865
+ # Finally, an entirely separate method could be developed to first identify
1866
+ # whether "building entrances" (a defined term in 90.1) actually require
1867
+ # vestibules as per specific code requirements. Food for thought.
1868
+ mth = "osut.is_vestibule"
1869
+ cl = openstudio.model.Space
1870
+
1871
+ if not isinstance(space, cl):
1872
+ return oslg.mismatch("space", space, cl, mth, CN.DBG, False)
1873
+
1874
+ id = space.nameString()
1875
+ m1 = "%s:vestibule" % id
1876
+ m2 = "%s:boolean" % m1
1877
+
1878
+ if space.additionalProperties().hasFeature("vestibule"):
1879
+ val = space.additionalProperties().getFeatureAsBoolean("vestibule")
1880
+
1881
+ if val:
1882
+ val = val.get()
1883
+
1884
+ if isinstance(val, bool):
1885
+ return val
1886
+ else:
1887
+ return oslg.invalid(m2, mth, 1, CN.ERR, False)
1888
+ else:
1889
+ return oslg.invalid(m1, mth, 1, CN.ERR, False)
1890
+
1891
+ if space.spaceType():
1892
+ type = space.spaceType().get()
1893
+ if "plenum" in type.nameString().lower(): return False
1894
+ if "vestibule" in type.nameString().lower(): return True
1895
+
1896
+ if type.standardsSpaceType():
1897
+ type = type.standardsSpaceType().get().lower()
1898
+ if "plenum" in type: return False
1899
+ if "vestibule" in type: return True
1900
+
1901
+ return False
1902
+
1903
+
1904
+ def is_plenum(space=None):
1905
+ """Validates whether a space is an indirectly-conditioned plenum.
1906
+
1907
+ Args:
1908
+ space (openstudio.model.Space):
1909
+ An OpenStudio space.
1910
+
1911
+ Returns:
1912
+ bool: Whether space is considered a plenum.
1913
+ False: If invalid input (see logs).
1914
+ """
1915
+ # Largely inspired from NREL's "space_plenum?":
1916
+ #
1917
+ # github.com/NREL/openstudio-standards/blob/
1918
+ # 58964222d25783e9da4ae292e375fb0d5c902aa5/lib/openstudio-standards/
1919
+ # standards/Standards.Space.rb#L1384
1920
+ #
1921
+ # Ideally, OSut's "is_plenum" should be in sync with OpenStudio SDK's
1922
+ # "isPlenum" method, which solely looks for either HVAC air mixer objects:
1923
+ # - AirLoopHVACReturnPlenum
1924
+ # - AirLoopHVACSupplyPlenum
1925
+ #
1926
+ # Of the OpenStudio-Standards Prototype models, only the LargeOffice
1927
+ # holds AirLoopHVACReturnPlenum objects. OpenStudio-Standards' method
1928
+ # "space_plenum?" indeed catches them by checking if the space is
1929
+ # "partofTotalFloorArea" (which internally has an "isPlenum" check). So
1930
+ # "isPlenum" closely follows ASHRAE 90.1 2016's definition of "plenum":
1931
+ #
1932
+ # "plenum": a compartment or chamber ...
1933
+ # - to which one or more ducts are connected
1934
+ # - that forms a part of the air distribution system, and
1935
+ # - that is NOT USED for occupancy or storage.
1936
+ #
1937
+ # Canadian NECB 2020 has the following (not as well) defined term:
1938
+ # "plenum": a chamber forming part of an air duct system.
1939
+ # ... we'll assume that a space shall also be considered
1940
+ # UNOCCUPIED if it's "part of an air duct system".
1941
+ #
1942
+ # As intended, "isPlenum" would NOT identify as a "plenum" any vented
1943
+ # UNCONDITIONED or UNENCLOSED attic or crawlspace - good. Yet "isPlenum"
1944
+ # would also ignore dead air spaces integrating ducted return air. The
1945
+ # SDK's "partofTotalFloorArea" would be more suitable in such cases, as
1946
+ # long as modellers have, a priori, set this parameter to FALSE.
1947
+ #
1948
+ # By initially relying on the SDK's "partofTotalFloorArea", "space_plenum?"
1949
+ # ends up catching a MUCH WIDER range of spaces, which aren't caught by
1950
+ # "isPlenum". This includes attics, crawlspaces, non-plenum air spaces above
1951
+ # ceiling tiles, and any other UNOCCUPIED space in a model. The term
1952
+ # "plenum" in this context is more of a catch-all shorthand - to be used
1953
+ # with caution. For instance, "space_plenum?" shouldn't be used (in
1954
+ # isolation) to determine whether an UNOCCUPIED space should have its
1955
+ # envelope insulated ("plenum") or not ("attic").
1956
+ #
1957
+ # In contrast to OpenStudio-Standards' "space_plenum?", OSut's "is_plenum"
1958
+ # strictly returns FALSE if a space is indeed "partofTotalFloorArea". It
1959
+ # also returns FALSE if the space is a vestibule. Otherwise, it needs more
1960
+ # information to determine if such an UNOCCUPIED space is indeed a
1961
+ # plenum. Beyond these 2x criteria, a space is considered a plenum if:
1962
+ #
1963
+ # CASE A: it includes the substring "plenum" (case insensitive) in its
1964
+ # spaceType's name, or in the latter's standardsSpaceType string;
1965
+ #
1966
+ # CASE B: "isPlenum" == TRUE in an OpenStudio model WITH HVAC airloops; OR
1967
+ #
1968
+ # CASE C: its zone holds an 'inactive' thermostat (i.e. can't extract valid
1969
+ # setpoints) in an OpenStudio model with setpoint temperatures.
1970
+ #
1971
+ # If a modeller is instead simply interested in identifying UNOCCUPIED
1972
+ # spaces that are INDIRECTLYCONDITIONED (not necessarily plenums), then the
1973
+ # following combination is likely more reliable and less confusing:
1974
+ # - SDK's partofTotalFloorArea == FALSE
1975
+ # - OSut's is_unconditioned == FALSE
1976
+ mth = "osut.is_plenum"
1977
+ cl = openstudio.model.Space
1978
+
1979
+ if not isinstance(space, cl):
1980
+ return oslg.mismatch("space", space, cl, mth, CN.DBG, False)
1981
+
1982
+ if space.partofTotalFloorArea(): return False
1983
+ if is_vestibule(space): return False
1984
+
1985
+ # CASE A: "plenum" spaceType.
1986
+ if space.spaceType():
1987
+ type = space.spaceType().get()
1988
+
1989
+ if "plenum" in type.nameString().lower():
1990
+ return True
1991
+
1992
+ if type.standardsSpaceType():
1993
+ type = type.standardsSpaceType().get().lower()
1994
+
1995
+ if "plenum" in type: return True
1996
+
1997
+ # CASE B: "isPlenum" == TRUE if airloops.
1998
+ if has_airLoopsHVAC(space.model()): return space.isPlenum()
1999
+
2000
+ # CASE C: zone holds an 'inactive' thermostat.
2001
+ zone = space.thermalZone()
2002
+ heated = has_heatingTemperatureSetpoints(space.model())
2003
+ cooled = has_coolingTemperatureSetpoints(space.model())
2004
+
2005
+ if heated or cooled:
2006
+ if not zone: return False
2007
+
2008
+ zone = zone.get()
2009
+ heat = maxHeatScheduledSetpoint(zone)
2010
+ cool = minCoolScheduledSetpoint(zone)
2011
+ if heat["spt"] or cool["spt"]: return False # directly CONDITIONED
2012
+ return heat["dual"] or cool["dual"] # FALSE if both are None
2013
+
2014
+ return False
2015
+
2016
+
2017
+ def setpoints(space=None):
2018
+ """Retrieves a space's (implicit or explicit) heating/cooling setpoints.
2019
+
2020
+ Args:
2021
+ space (OpenStudio::Model::Space):
2022
+ An OpenStudio space.
2023
+ Returns:
2024
+ dict:
2025
+ - heating (float): heating setpoint (None if invalid inputs - see logs).
2026
+ - cooling (float): cooling setpoint (None if invalid inputs - see logs).
2027
+ """
2028
+ mth = "osut.setpoints"
2029
+ cl1 = openstudio.model.Space
2030
+ cl2 = str
2031
+ res = dict(heating=None, cooling=None)
2032
+ tg1 = "space_conditioning_category"
2033
+ tg2 = "indirectlyconditioned"
2034
+ cts = ["nonresconditioned", "resconditioned", "semiheated", "unconditioned"]
2035
+ cnd = None
2036
+
2037
+ if not isinstance(space, cl1):
2038
+ return oslg.mismatch("space", space, cl1, mth, CN.DBG, res)
2039
+
2040
+ # 1. Check for OpenStudio-Standards' space conditioning categories.
2041
+ if space.additionalProperties().hasFeature(tg1):
2042
+ cnd = space.additionalProperties().getFeatureAsString(tg1)
2043
+
2044
+ if cnd:
2045
+ cnd = cnd.get()
2046
+
2047
+ if cnd.lower() in cts:
2048
+ if cnd.lower() == "unconditioned": return res
2049
+ else:
2050
+ oslg.invalid("%s:%s" % (tg1, cnd), mth, 0, CN.ERR)
2051
+ cnd = None
2052
+ else:
2053
+ cnd = None
2054
+
2055
+ # 2. Check instead OSut's INDIRECTLYCONDITIONED (parent space) link.
2056
+ if not cnd:
2057
+ id = space.additionalProperties().getFeatureAsString(tg2)
2058
+
2059
+ if id:
2060
+ id = id.get()
2061
+ dad = space.model().getSpaceByName(id)
2062
+
2063
+ if dad:
2064
+ # Now focus on 'parent' space of INDIRECTLYCONDITIONED space.
2065
+ space = dad.get()
2066
+ cnd = tg2
2067
+ else:
2068
+ log(ERR, "Unknown space %s (%s)" % (id, mth))
2069
+
2070
+ # 3. Fetch space setpoints (if model indeed holds valid setpoints).
2071
+ heated = has_heatingTemperatureSetpoints(space.model())
2072
+ cooled = has_coolingTemperatureSetpoints(space.model())
2073
+ zone = space.thermalZone()
2074
+
2075
+ if heated or cooled:
2076
+ if not zone: return res # UNCONDITIONED
2077
+
2078
+ zone = zone.get()
2079
+ res["heating"] = maxHeatScheduledSetpoint(zone)["spt"]
2080
+ res["cooling"] = minCoolScheduledSetpoint(zone)["spt"]
2081
+
2082
+ # 4. Reset if AdditionalProperties were found & valid.
2083
+ if cnd:
2084
+ if cnd.lower() == "unconditioned":
2085
+ res["heating"] = None
2086
+ res["cooling"] = None
2087
+ elif cnd.lower() == "semiheated":
2088
+ if not res["heating"]: res["heating"] = 14.0
2089
+ res["cooling"] = None
2090
+ elif "conditioned" in cnd.lower():
2091
+ # "nonresconditioned", "resconditioned" or "indirectlyconditioned"
2092
+ if not res["heating"]: res["heating"] = 21.0 # default
2093
+ if not res["cooling"]: res["cooling"] = 24.0 # default
2094
+
2095
+ # 5. Reset if plenum.
2096
+ if is_plenum(space):
2097
+ if not res["heating"]: res["heating"] = 21.0 # default
2098
+ if not res["cooling"]: res["cooling"] = 24.0 # default
2099
+
2100
+ return res
2101
+
2102
+
2103
+ def is_unconditioned(space=None):
2104
+ """Validates if a space is UNCONDITIONED.
2105
+
2106
+ Args:
2107
+ space (openstudio.model.Space):
2108
+ An OpenStudio space.
2109
+ Returns:
2110
+ bool: Whether space is considered UNCONDITIONED.
2111
+ False: If invalid input (see logs).
2112
+ """
2113
+ mth = "osut.is_unconditioned"
2114
+ cl = openstudio.model.Space
2115
+
2116
+ if not isinstance(space, cl):
2117
+ return oslg.mismatch("space", space, cl, mth, CN.DBG, False)
2118
+
2119
+ if setpoints(space)["heating"]: return False
2120
+ if setpoints(space)["cooling"]: return False
2121
+
2122
+ return True
2123
+
2124
+
2125
+ def is_refrigerated(space=None):
2126
+ """Confirms if a space can be considered as REFRIGERATED.
2127
+
2128
+ Args:
2129
+ space (openstudio.model.Space):
2130
+ An OpenStudio space.
2131
+
2132
+ Returns:
2133
+ bool: Whether space is considered REFRIGERATED.
2134
+ False: If invalid inputs (see logs).
2135
+ """
2136
+ mth = "osut.is_refrigerated"
2137
+ cl = openstudio.model.Space
2138
+ tg0 = "refrigerated"
2139
+
2140
+ if not isinstance(space, cl):
2141
+ return oslg.mismatch("space", space, cl, mth, CN.DBG, False)
2142
+
2143
+ id = space.nameString()
2144
+
2145
+ # 1. First check OSut's REFRIGERATED status.
2146
+ status = space.additionalProperties().getFeatureAsString(tg0)
2147
+
2148
+ if status:
2149
+ status = status.get()
2150
+ if isinstance(status, bool): return status
2151
+ log(ERR, "Unknown %s REFRIGERATED %s (%s)" % (id, status, mth))
2152
+
2153
+ # 2. Else, compare design heating/cooling setpoints.
2154
+ stps = setpoints(space)
2155
+ if stps["heating"]: return False
2156
+ if not stps["cooling"]: return False
2157
+ if stps["cooling"] < 15: return True
2158
+
2159
+ return False
2160
+
2161
+
2162
+ def is_semiheated(space=None):
2163
+ """Confirms if a space can be considered as SEMIHEATED as per NECB 2020
2164
+ 1.2.1.2. 2): Design heating setpoint < 15°C (and non-REFRIGERATED).
2165
+
2166
+ Args:
2167
+ space (openstudio.model.space):
2168
+ An OpenStudio space.
2169
+
2170
+ Returns:
2171
+ bool: Whether space is considered SEMIHEATED.
2172
+ False: If invalid inputs (see logs).
2173
+ """
2174
+ mth = "osut.is_semiheated"
2175
+ cl = openstudio.model.Space
2176
+
2177
+ if not isinstance(space, cl):
2178
+ return oslg.mismatch("space", space, cl, mth, CN.DBG, False)
2179
+ if is_refrigerated(space):
2180
+ return False
2181
+
2182
+ stps = setpoints(space)
2183
+ if stps["cooling"]: return False
2184
+ if not stps["heating"]: return False
2185
+ if stps["heating"] < 15: return True
2186
+
2187
+ return False
2188
+
2189
+
2190
+ def availabilitySchedule(model=None, avl=""):
2191
+ """Generates an HVAC availability schedule (if missing from model).
2192
+
2193
+ Args:
2194
+ model (openstudio.model.Model):
2195
+ An OpenStudio model.
2196
+ avl (str):
2197
+ Seasonal availability choice (optional, default "ON").
2198
+
2199
+ Returns:
2200
+ OpenStudio::Model::Schedule: An OpenStudio HVAC availability schedule.
2201
+ None: If invalid input (see logs).
2202
+ """
2203
+ mth = "osut.availabilitySchedule"
2204
+ cl = openstudio.model.Model
2205
+ limits = None
2206
+
2207
+ if not isinstance(model, cl):
2208
+ return oslg.mismatch("model", model, cl, mth)
2209
+
2210
+ try:
2211
+ avl = str(avl)
2212
+ except:
2213
+ return oslg.mismatch("availability", avl, str, mth, CN.ERR)
2214
+
2215
+ # Either fetch availability ScheduleTypeLimits object, or create one.
2216
+ for l in model.getScheduleTypeLimitss():
2217
+ id = l.nameString().lower()
2218
+
2219
+ if limits: break
2220
+ if not l.lowerLimitValue(): continue
2221
+ if not l.upperLimitValue(): continue
2222
+ if not l.numericType(): continue
2223
+ if not int(l.lowerLimitValue().get()) == 0: continue
2224
+ if not int(l.upperLimitValue().get()) == 1: continue
2225
+ if not l.numericType().get().lower() == "discrete": continue
2226
+ if not l.unitType().lower() == "availability": continue
2227
+ if id != "hvac operation scheduletypelimits": continue
2228
+
2229
+ limits = l
2230
+
2231
+ if not limits:
2232
+ limits = openstudio.model.ScheduleTypeLimits(model)
2233
+ limits.setName("HVAC Operation ScheduleTypeLimits")
2234
+ limits.setLowerLimitValue(0)
2235
+ limits.setUpperLimitValue(1)
2236
+ limits.setNumericType("Discrete")
2237
+ limits.setUnitType("Availability")
2238
+
2239
+ time = openstudio.Time(0,24)
2240
+ secs = time.totalSeconds()
2241
+ on = openstudio.model.ScheduleDay(model, 1)
2242
+ off = openstudio.model.ScheduleDay(model, 0)
2243
+
2244
+ # Seasonal availability start/end dates.
2245
+ year = model.yearDescription()
2246
+
2247
+ if not year:
2248
+ return oslg.empty("yearDescription", mth, CN.ERR)
2249
+
2250
+ year = year.get()
2251
+ may01 = year.makeDate(openstudio.MonthOfYear("May"), 1)
2252
+ oct31 = year.makeDate(openstudio.MonthOfYear("Oct"), 31)
2253
+
2254
+ if oslg.trim(avl).lower() == "winter":
2255
+ # available from November 1 to April 30 (6 months)
2256
+ val = 1
2257
+ sch = off
2258
+ nom = "WINTER Availability SchedRuleset"
2259
+ dft = "WINTER Availability dftDaySched"
2260
+ tag = "May-Oct WINTER Availability SchedRule"
2261
+ day = "May-Oct WINTER SchedRule Day"
2262
+ elif oslg.trim(avl).lower() == "summer":
2263
+ # available from May 1 to October 31 (6 months)
2264
+ val = 0
2265
+ sch = on
2266
+ nom = "SUMMER Availability SchedRuleset"
2267
+ dft = "SUMMER Availability dftDaySched"
2268
+ tag = "May-Oct SUMMER Availability SchedRule"
2269
+ day = "May-Oct SUMMER SchedRule Day"
2270
+ elif oslg.trim(avl).lower() == "off":
2271
+ # never available
2272
+ val = 0
2273
+ sch = on
2274
+ nom = "OFF Availability SchedRuleset"
2275
+ dft = "OFF Availability dftDaySched"
2276
+ tag = ""
2277
+ day = ""
2278
+ else:
2279
+ # always available
2280
+ val = 1
2281
+ sch = on
2282
+ nom = "ON Availability SchedRuleset"
2283
+ dft = "ON Availability dftDaySched"
2284
+ tag = ""
2285
+ day = ""
2286
+
2287
+ # Fetch existing schedule.
2288
+ ok = True
2289
+ schedule = model.getScheduleByName(nom)
2290
+
2291
+ if schedule:
2292
+ schedule = schedule.get().to_ScheduleRuleset()
2293
+
2294
+ if schedule:
2295
+ schedule = schedule.get()
2296
+ default = schedule.defaultDaySchedule()
2297
+ ok = ok and default.nameString() == dft
2298
+ ok = ok and len(default.times()) == 1
2299
+ ok = ok and len(default.values()) == 1
2300
+ ok = ok and default.times()[0] == time
2301
+ ok = ok and default.values()[0] == val
2302
+ rules = schedule.scheduleRules()
2303
+ ok = ok and len(rules) < 2
2304
+
2305
+ if len(rules) == 1:
2306
+ rule = rules[0]
2307
+ ok = ok and rule.nameString() == tag
2308
+ ok = ok and rule.startDate()
2309
+ ok = ok and rule.endDate()
2310
+ ok = ok and rule.startDate().get() == may01
2311
+ ok = ok and rule.endDate().get() == oct31
2312
+ ok = ok and rule.applyAllDays()
2313
+
2314
+ d = rule.daySchedule()
2315
+ ok = ok and d.nameString() == day
2316
+ ok = ok and len(d.times()) == 1
2317
+ ok = ok and len(d.values()) == 1
2318
+ ok = ok and d.times()[0].totalSeconds() == secs
2319
+ ok = ok and int(d.values()[0]) != val
2320
+
2321
+ if ok: return schedule
2322
+
2323
+ schedule = openstudio.model.ScheduleRuleset(model)
2324
+ schedule.setName(nom)
2325
+
2326
+ if not schedule.setScheduleTypeLimits(limits):
2327
+ oslg.log(ERR, "'%s': Can't set schedule type limits (%s)" % (nom, mth))
2328
+ return nil
2329
+
2330
+ if not schedule.defaultDaySchedule().addValue(time, val):
2331
+ oslg.log(ERR, "'%s': Can't set default day schedule (%s)" % (nom, mth))
2332
+ return None
2333
+
2334
+ schedule.defaultDaySchedule().setName(dft)
2335
+
2336
+ if tag:
2337
+ rule = openstudio.model.ScheduleRule(schedule, sch)
2338
+ rule.setName(tag)
2339
+
2340
+ if not rule.setStartDate(may01):
2341
+ oslg.log(ERR, "'%s': Can't set start date (%s)" % (tag, mth))
2342
+ return None
2343
+
2344
+ if not rule.setEndDate(oct31):
2345
+ oslg.log(ERR, "'%s': Can't set end date (%s)" % (tag, mth))
2346
+ return None
2347
+
2348
+ if not rule.setApplyAllDays(True):
2349
+ oslg.log(ERR, "'%s': Can't apply to all days (%s)" % (tag, mth))
2350
+ return None
2351
+
2352
+ rule.daySchedule().setName(day)
2353
+
2354
+ return schedule
2355
+
2356
+
2357
+ def transforms(group=None) -> dict:
2358
+ """"Returns OpenStudio site/space transformation & rotation angle.
2359
+
2360
+ Args:
2361
+ group:
2362
+ A site or space PlanarSurfaceGroup object.
2363
+
2364
+ Returns:
2365
+ A transformation + rotation dictionary:
2366
+ - t (openstudio.Transformation): site/space transformation.
2367
+ None: if invalid inputs (see logs).
2368
+ - r (float): Site/space rotation angle [0,2PI) radians.
2369
+ None: if invalid inputs (see logs).
2370
+
2371
+ """
2372
+ mth = "osut.transforms"
2373
+ res = dict(t=None, r=None)
2374
+ cl = openstudio.model.PlanarSurfaceGroup
2375
+
2376
+ if isinstance(group, cl):
2377
+ return oslg.mismatch("group", group, cl, mth, CN.DBG, res)
2378
+
2379
+ mdl = group.model()
2380
+
2381
+ res["t"] = group.siteTransformation()
2382
+ res["r"] = group.directionofRelativeNorth() + mdl.getBuilding().northAxis()
2383
+
2384
+ return res
2385
+
2386
+
2387
+ def trueNormal(s=None, r=0):
2388
+ """Returns the site/true outward normal vector of a surface.
2389
+
2390
+ Args:
2391
+ s (OpenStudio::Model::PlanarSurface):
2392
+ An OpenStudio Planar Surface.
2393
+ r (float):
2394
+ a group/site rotation angle [0,2PI) radians
2395
+
2396
+ Returns:
2397
+ openstudio.Vector3d: A surface's true normal vector.
2398
+ None : If invalid input (see logs).
2399
+
2400
+ """
2401
+ mth = "osut.trueNormal"
2402
+ cl = openstudio.model.PlanarSurface
2403
+
2404
+ if not isinstance(s, cl):
2405
+ return oslg.mismatch("surface", s, cl, mth)
2406
+
2407
+ try:
2408
+ r = float(r)
2409
+ except:
2410
+ return oslg.mismatch("rotation", r, float, mth)
2411
+
2412
+ r = float(-r) * math.pi / 180.0
2413
+
2414
+ vx = s.outwardNormal().x * math.cos(r) - s.outwardNormal().y * math.sin(r)
2415
+ vy = s.outwardNormal().x * math.sin(r) + s.outwardNormal().y * math.cos(r)
2416
+ vz = s.outwardNormal().z
2417
+
2418
+ return openstudio.Point3d(vx, vy, vz) - openstudio.Point3d(0, 0, 0)
2419
+
2420
+
2421
+ def scalar(v=None, m=0) -> openstudio.Vector3d:
2422
+ """Returns scalar product of an OpenStudio Vector3d.
2423
+
2424
+ Args:
2425
+ v (OpenStudio::Vector3d):
2426
+ An OpenStudio vector.
2427
+ m (float):
2428
+ A scalar.
2429
+
2430
+ Returns:
2431
+ (openstudio.Vector3d) scaled points (see logs if (0,0,0)).
2432
+
2433
+ """
2434
+ mth = "osut.scalar"
2435
+ cl = openstudio.Vector3d
2436
+ v0 = openstudio.Vector3d()
2437
+
2438
+ if not isinstance(v, cl):
2439
+ return oslg.mismatch("vector", v, cl, mth, CN.DBG, v0)
2440
+
2441
+ try:
2442
+ m = float(m)
2443
+ except:
2444
+ return oslg.mismatch("scalar", m, float, mth, CN.DBG, v0)
2445
+
2446
+ v0 = openstudio.Vector3d(m * v.x(), m * v.y(), m * v.z())
2447
+
2448
+ return v0
2449
+
2450
+
2451
+ def to_p3Dv(pts=None) -> openstudio.Point3dVector:
2452
+ """Returns OpenStudio 3D points as an OpenStudio point vector, validating
2453
+ points in the process.
2454
+
2455
+ Args:
2456
+ pts (list): OpenStudio 3D points.
2457
+
2458
+ Returns:
2459
+ openstudio.Point3dVector: Vector of 3D points (see logs if empty).
2460
+
2461
+ """
2462
+ mth = "osut.to_p3Dv"
2463
+ cl = openstudio.Point3d
2464
+ v = openstudio.Point3dVector()
2465
+
2466
+ if isinstance(pts, cl):
2467
+ v.append(pts)
2468
+ return v
2469
+ elif isinstance(pts, openstudio.Point3dVector):
2470
+ return pts
2471
+ elif isinstance(pts, openstudio.model.PlanarSurface):
2472
+ return pts.vertices()
2473
+
2474
+ try:
2475
+ pts = list(pts)
2476
+ except:
2477
+ return oslg.mismatch("points", pts, list, mth, CN.DBG, v)
2478
+
2479
+ for pt in pts:
2480
+ if not isinstance(pt, cl):
2481
+ return oslg.mismatch("point", pt, cl, mth, CN.DBG, v)
2482
+
2483
+ for pt in pts:
2484
+ v.append(openstudio.Point3d(pt.x(), pt.y(), pt.z()))
2485
+
2486
+ return v
2487
+
2488
+
2489
+ def is_same_vtx(s1=None, s2=None, indexed=True) -> bool:
2490
+ """Returns True if 2 sets of OpenStudio 3D points are nearly equal.
2491
+
2492
+ Args:
2493
+ s1:
2494
+ 1st set of OpenStudio 3D points
2495
+ s2:
2496
+ 2nd set of OpenStudio 3D points
2497
+ indexed (bool):
2498
+ whether to attempt to harmonize vertex sequence
2499
+
2500
+ Returns:
2501
+ bool: Whether sets are nearly equal (within TOL).
2502
+ False: If invalid input (see logs).
2503
+
2504
+ """
2505
+ s1 = list(to_p3Dv(s1))
2506
+ s2 = list(to_p3Dv(s2))
2507
+ if not s1: return False
2508
+ if not s2: return False
2509
+ if len(s1) != len(s2): return False
2510
+ if not isinstance(indexed, bool): indexed = True
2511
+
2512
+ if indexed:
2513
+ xOK = abs(s1[0].x() - s2[0].x()) < CN.TOL
2514
+ yOK = abs(s1[0].y() - s2[0].y()) < CN.TOL
2515
+ zOK = abs(s1[0].z() - s2[0].z()) < CN.TOL
2516
+
2517
+ if xOK and yOK and zOK and len(s1) == 1:
2518
+ return True
2519
+ else:
2520
+ indx = None
2521
+
2522
+ for i, pt in enumerate(s2):
2523
+ if indx: continue
2524
+
2525
+ xOK = abs(s1[0].x() - s2[i].x()) < CN.TOL
2526
+ yOK = abs(s1[0].y() - s2[i].y()) < CN.TOL
2527
+ zOK = abs(s1[0].z() - s2[i].z()) < CN.TOL
2528
+
2529
+ if xOK and yOK and zOK: indx = i
2530
+
2531
+ if not indx: return False
2532
+
2533
+ s2 = collections.deque(s2)
2534
+ s2.rotate(indx)
2535
+ s2 = list(s2)
2536
+
2537
+ # openstudio.isAlmostEqual3dPt(p1, p2, TOL) # ... from v350 onwards.
2538
+ for i in range(len(s1)):
2539
+ xOK = abs(s1[i].x() - s2[i].x()) < CN.TOL
2540
+ yOK = abs(s1[i].y() - s2[i].y()) < CN.TOL
2541
+ zOK = abs(s1[i].z() - s2[i].z()) < CN.TOL
2542
+
2543
+ if not xOK or not yOK or not zOK: return False
2544
+
2545
+ return True
2546
+
2547
+
2548
+ def facets(spaces=[], boundary="all", type="all", sides=[]) -> list:
2549
+ """Returns an array of OpenStudio space surfaces or subsurfaces that match
2550
+ criteria, e.g. exterior, north-east facing walls in hotel "lobby". Note
2551
+ that the 'sides' list relies on space coordinates (not building or site
2552
+ coordinates). Also, the 'sides' list is exclusive (not inclusive), e.g.
2553
+ walls strictly facing north or east would not be returned if 'sides' holds
2554
+ ["north", "east"]. No outside boundary condition filters if 'boundary'
2555
+ argument == "all". No surface type filters if 'type' argument == "all".
2556
+
2557
+ Args:
2558
+ spaces (list of openstudio.model.Space):
2559
+ Target spaces.
2560
+ boundary (str):
2561
+ OpenStudio outside boundary condition.
2562
+ type (str):
2563
+ OpenStudio surface (or subsurface) type.
2564
+ sides (list):
2565
+ Direction keys, e.g. "north" (see osut.sidz())
2566
+
2567
+ Returns:
2568
+ list of openstudio.model.Surface: Surfaces (may be empty, no logs).
2569
+ list of openstudio.model.SubSurface: SubSurfaces (may be empty, no logs).
2570
+ """
2571
+ mth = "osut.facets"
2572
+
2573
+ spaces = [spaces] if isinstance(spaces, openstudio.model.Space) else spaces
2574
+
2575
+ try:
2576
+ spaces = list(spaces)
2577
+ except:
2578
+ return []
2579
+
2580
+ sides = [sides] if isinstance(sides, str) else sides
2581
+
2582
+ try:
2583
+ sides = list(sides)
2584
+ except:
2585
+ return []
2586
+
2587
+ faces = []
2588
+ boundary = oslg.trim(boundary).lower()
2589
+ type = oslg.trim(type).lower()
2590
+ if not boundary: return []
2591
+ if not type: return []
2592
+
2593
+ # Filter sides. If 'sides' is initially empty, return all surfaces of
2594
+ # matching type and outside boundary condition.
2595
+ if sides:
2596
+ sides = [side for side in sides if side in sidz()]
2597
+
2598
+ if not sides: return []
2599
+
2600
+ for space in spaces:
2601
+ if not isinstance(space, openstudio.model.Space): return []
2602
+
2603
+ for s in space.surfaces():
2604
+ if boundary != "all":
2605
+ if s.outsideBoundaryCondition().lower() != boundary: continue
2606
+
2607
+ if type != "all":
2608
+ if s.surfaceType().lower() != type: continue
2609
+
2610
+ if sides:
2611
+ aims = []
2612
+
2613
+ if s.outwardNormal().z() > CN.TOL: aims.append("top")
2614
+ if s.outwardNormal().z() < -CN.TOL: aims.append("bottom")
2615
+ if s.outwardNormal().y() > CN.TOL: aims.append("north")
2616
+ if s.outwardNormal().x() > CN.TOL: aims.append("east")
2617
+ if s.outwardNormal().y() < -CN.TOL: aims.append("south")
2618
+ if s.outwardNormal().x() < -CN.TOL: aims.append("west")
2619
+
2620
+ if all([side in aims for side in sides]):
2621
+ faces.append(s)
2622
+ else:
2623
+ faces.append(s)
2624
+
2625
+ for space in spaces:
2626
+ for s in space.surfaces():
2627
+ if boundary != "all":
2628
+ if s.outsideBoundaryCondition().lower() != boundary: continue
2629
+
2630
+ for sub in s.subSurfaces():
2631
+ if type != "all":
2632
+ if sub.subSurfaceType().lower() != type: continue
2633
+
2634
+ if sides:
2635
+ aims = []
2636
+
2637
+ if sub.outwardNormal().z() > CN.TOL: aims.append("top")
2638
+ if sub.outwardNormal().z() < -CN.TOL: aims.append("bottom")
2639
+ if sub.outwardNormal().y() > CN.TOL: aims.append("north")
2640
+ if sub.outwardNormal().x() > CN.TOL: aims.append("east")
2641
+ if sub.outwardNormal().y() < -CN.TOL: aims.append("south")
2642
+ if sub.outwardNormal().x() < -CN.TOL: aims.append("west")
2643
+
2644
+ if all([side in aims for side in sides]):
2645
+ faces.append(sub)
2646
+ else:
2647
+ faces.append(sub)
2648
+
2649
+ return faces
2650
+
2651
+
2652
+ def genSlab(pltz=[], z=0):
2653
+ """Generates an OpenStudio 3D point vector of a composite floor "slab", a
2654
+ 'union' of multiple rectangular, horizontal floor "plates". Each plate
2655
+ must either share an edge with (or encompass or overlap) any of the
2656
+ preceding plates in the array. The generated slab may not be convex.
2657
+
2658
+ Args:
2659
+ pltz (list):
2660
+ Collection of individual floor plates (dicts), each holding:
2661
+ - "x" (float): Left corner of plate origin (bird's eye view).
2662
+ - "y" (float): Bottom corner of plate origin (bird's eye view).
2663
+ - "dx" (float): Plate width (bird's eye view).
2664
+ - "dy" (float): Plate depth (bird's eye view)
2665
+ - "z" (float): Z-axis coordinate.
2666
+
2667
+ Returns:
2668
+ openstudio.point3dVector: Slab vertices (see logs if empty).
2669
+ """
2670
+ mth = "osut.genSlab"
2671
+ slb = openstudio.Point3dVector()
2672
+ bkp = openstudio.Point3dVector()
2673
+
2674
+ # Input validation.
2675
+ if not isinstance(pltz, list):
2676
+ return oslg.mismatch("plates", pltz, list, mth, CN.DBG, slb)
2677
+
2678
+ try:
2679
+ z = float(z)
2680
+ except:
2681
+ return oslg.mismatch("Z", z, float, mth, CN.DBG, slb)
2682
+
2683
+ for i, plt in enumerate(pltz):
2684
+ id = "plate # %d (index %d)" % (i+1, i)
2685
+
2686
+ if not isinstance(plt, dict):
2687
+ return oslg.mismatch(id, plt, dict, mth, CN.DBG, slb)
2688
+
2689
+ if "x" not in plt: return oslg.hashkey(id, plt, "x", mth, CN.DBG, slb)
2690
+ if "y" not in plt: return oslg.hashkey(id, plt, "y", mth, CN.DBG, slb)
2691
+ if "dx" not in plt: return oslg.hashkey(id, plt, "dx", mth, CN.DBG, slb)
2692
+ if "dy" not in plt: return oslg.hashkey(id, plt, "dy", mth, CN.DBG, slb)
2693
+
2694
+ x = plt["x" ]
2695
+ y = plt["y" ]
2696
+ dx = plt["dx"]
2697
+ dy = plt["dy"]
2698
+
2699
+ try:
2700
+ x = float(x)
2701
+ except:
2702
+ oslg.mismatch("%s X" % id, x, float, mth, CN.DBG, slb)
2703
+
2704
+ try:
2705
+ y = float(y)
2706
+ except:
2707
+ oslg.mismatch("%s Y" % id, y, float, mth, CN.DBG, slb)
2708
+
2709
+ try:
2710
+ dx = float(dx)
2711
+ except:
2712
+ oslg.mismatch("%s dX" % id, dx, float, mth, CN.DBG, slb)
2713
+
2714
+ try:
2715
+ dy = float(dy)
2716
+ except:
2717
+ oslg.mismatch("%s dY" % id, dy, float, mth, CN.DBG, slb)
2718
+
2719
+ if abs(dx) < CN.TOL: return oslg.zero("%s dX" % id, mth, CN.ERR, slb)
2720
+ if abs(dy) < CN.TOL: return oslg.zero("%s dY" % id, mth, CN.ERR, slb)
2721
+
2722
+ # Join plates.
2723
+ for i, plt in enumerate(pltz):
2724
+ id = "plate # %d (index %d)" % (i+1, i)
2725
+
2726
+ x = plt["x" ]
2727
+ y = plt["y" ]
2728
+ dx = plt["dx"]
2729
+ dy = plt["dy"]
2730
+
2731
+ # Adjust X if dX < 0.
2732
+ if dx < 0: x -= -dx
2733
+ if dx < 0: dx = -dx
2734
+
2735
+ # Adjust Y if dY < 0.
2736
+ if dy < 0: y -= -dy
2737
+ if dy < 0: dy = -dy
2738
+
2739
+ vtx = []
2740
+ vtx.append(openstudio.Point3d(x + dx, y + dy, 0))
2741
+ vtx.append(openstudio.Point3d(x + dx, y, 0))
2742
+ vtx.append(openstudio.Point3d(x, y, 0))
2743
+ vtx.append(openstudio.Point3d(x, y + dy, 0))
2744
+
2745
+ if slb:
2746
+ slab = openstudio.join(slb, vtx, CN.TOL2)
2747
+
2748
+ if slab:
2749
+ slb = slab.get()
2750
+ else:
2751
+ return oslg.invalid(id, mth, 0, CN.ERR, bkp)
2752
+ else:
2753
+ slb = vtx
2754
+
2755
+ # Once joined, re-adjust Z-axis coordinates.
2756
+ if abs(z) > CN.TOL:
2757
+ vtx = openstudio.Point3dVector()
2758
+
2759
+ for pt in slb: vtx.append(openstudio.Point3d(pt.x(), pt.y(), z))
2760
+
2761
+ slb = vtx
2762
+
2763
+ return slb