osut 0.7.0__py3-none-any.whl → 0.8.1__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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # BSD 3-Clause License
2
2
  #
3
- # Copyright (c) 2022-2025, rd2
3
+ # Copyright (c) 2022-2026, rd2
4
4
  #
5
5
  # Redistribution and use in source and binary forms, with or without
6
6
  # modification, are permitted provided that the following conditions are met:
@@ -36,15 +36,24 @@ from dataclasses import dataclass
36
36
 
37
37
  @dataclass(frozen=True)
38
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
- HEAD = 2.032 # standard 80" door
47
- SILL = 0.762 # standard 30" window sill
39
+ DBG = oslg.CN.DEBUG # see github.com/rd2/pyOSlg
40
+ INF = oslg.CN.INFO # see github.com/rd2/pyOSlg
41
+ WRN = oslg.CN.WARN # see github.com/rd2/pyOSlg
42
+ ERR = oslg.CN.ERROR # see github.com/rd2/pyOSlg
43
+ FTL = oslg.CN.FATAL # see github.com/rd2/pyOSlg
44
+ TOL = 0.01 # default distance tolerance (m)
45
+ TOL2 = TOL * TOL # default area tolerance (m2)
46
+ HEAD = 2.032 # standard 80" door
47
+ SILL = 0.762 # standard 30" window sill
48
+ NS = "nameString" # OpenStudio object identifier method
49
+ DMIN = 0.010 # min. insulating material thickness
50
+ DMAX = 1.000 # max. insulating material thickness
51
+ KMIN = 0.010 # min. insulating material thermal conductivity
52
+ KMAX = 2.000 # max. insulating material thermal conductivity
53
+ UMAX = KMAX / DMIN # material USi upper limit, 200.000
54
+ UMIN = KMIN / DMAX # material USi lower limit, 0.010
55
+ RMIN = 1.0 / UMAX # material RSi lower limit, 0.005 (or R-IP 0.03)
56
+ RMAX = 1.0 / UMIN # material RSi upper limit, 100.000 (or R-IP 567.80)
48
57
  CN = _CN()
49
58
 
50
59
  # General surface orientations (see 'facets' method).
@@ -300,6 +309,456 @@ def clamp(value, minimum, maximum) -> float:
300
309
  return value
301
310
 
302
311
 
312
+ def areStandardOpaqueLayers(lc=None) -> bool:
313
+ """Validates if every material in a layered construction is standard/opaque.
314
+
315
+ Args:
316
+ lc (openstudio.model.LayeredConstruction):
317
+ an OpenStudio layered construction
318
+
319
+ Returns:
320
+ True: If all layers are valid (standard & opaque).
321
+ False: If invalid inputs (see logs).
322
+
323
+ """
324
+ mth = "osut.areStandardOpaqueLayers"
325
+ cl = openstudio.model.LayeredConstruction
326
+
327
+ if not isinstance(lc, cl):
328
+ return oslg.mismatch("lc", lc, cl, mth, CN.DBG, 0.0)
329
+
330
+ for m in lc.layers():
331
+ if not m.to_StandardOpaqueMaterial(): return False
332
+
333
+ return True
334
+
335
+
336
+ def thickness(lc=None) -> float:
337
+ """Returns total (standard opaque) layered construction thickness (m).
338
+
339
+ Args:
340
+ lc (openstudio.model.LayeredConstruction):
341
+ an OpenStudio layered construction
342
+
343
+ Returns:
344
+ float: A standard opaque construction thickness.
345
+ 0.0: If invalid inputs (see logs).
346
+
347
+ """
348
+ mth = "osut.thickness"
349
+ cl = openstudio.model.LayeredConstruction
350
+ d = 0.0
351
+
352
+ if not isinstance(lc, cl):
353
+ return oslg.mismatch("lc", lc, cl, mth, CN.DBG, 0.0)
354
+ if not areStandardOpaqueLayers(lc):
355
+ oslg.log(CN.ERR, "holding non-StandardOpaqueMaterial(s) %s" % mth)
356
+ return d
357
+
358
+ for m in lc.layers(): d += m.thickness()
359
+
360
+ return d
361
+
362
+
363
+ def glazingAirFilmRSi(usi=5.85) -> float:
364
+ """Returns total air film resistance of a fenestrated construction (m2•K/W).
365
+
366
+ Args:
367
+ usi (float):
368
+ A fenestrated construction's U-factor (W/m2•K).
369
+
370
+ Returns:
371
+ float: Total air film resistances.
372
+ 0.1216: If invalid input (see logs).
373
+
374
+ """
375
+ # The sum of thermal resistances of calculated exterior and interior film
376
+ # coefficients under standard winter conditions are taken from:
377
+ #
378
+ # https://bigladdersoftware.com/epx/docs/9-6/engineering-reference/
379
+ # window-calculation-module.html#simple-window-model
380
+ #
381
+ # These remain acceptable approximations for flat windows, yet likely
382
+ # unsuitable for subsurfaces with curved or projecting shapes like domed
383
+ # skylights. The solution here is considered an adequate fix for reporting,
384
+ # awaiting eventual OpenStudio (and EnergyPlus) upgrades to report NFRC 100
385
+ # (or ISO) air film resistances under standard winter conditions.
386
+ #
387
+ # For U-factors above 8.0 W/m2•K (or invalid input), the function returns
388
+ # 0.1216 m2•K/W, which corresponds to a construction with a single glass
389
+ # layer thickness of 2mm & k = ~0.6 W/m.K.
390
+ #
391
+ # The EnergyPlus Engineering calculations were designed for vertical
392
+ # windows, not for horizontal, slanted or domed surfaces - use with caution.
393
+ mth = "osut.glazingAirFilmRSi"
394
+ val = 0.1216
395
+
396
+ try:
397
+ usi = float(usi)
398
+ except:
399
+ return oslg.mismatch("usi", usi, float, mth, CN.DBG, val)
400
+
401
+ if usi > 8.0:
402
+ return oslg.invalid("usi", mth, 1, CN.WRN, val)
403
+ elif usi < 0:
404
+ return oslg.negative("usi", mth, CN.WRN, val)
405
+ elif abs(usi) < CN.TOL:
406
+ return oslg.zero("usi", mth, CN.WRN, val)
407
+
408
+ rsi = 1 / (0.025342 * usi + 29.163853) # exterior film, next interior film
409
+
410
+ if usi < 5.85:
411
+ return rsi + 1 / (0.359073 * math.log(usi) + 6.949915)
412
+
413
+ return rsi + 1 / (1.788041 * usi - 2.886625)
414
+
415
+
416
+ def rsi(lc=None, film=0.0, t=0.0) -> float:
417
+ """Returns a construction's 'standard calc' thermal resistance (m2•K/W),
418
+ which includes air film resistances. It excludes insulating effects of
419
+ shades, screens, etc. in the case of fenestrated constructions. Adapted
420
+ from BTAP's 'Material' Module "get_conductance" (P. Lopez).
421
+
422
+ Args:
423
+ lc (openstudio.model.LayeredConstruction):
424
+ an OpenStudio layered construction
425
+ film (float):
426
+ thermal resistance of surface air films (m2•K/W)
427
+ t (float):
428
+ gas temperature (°C) (optional)
429
+
430
+ Returns:
431
+ float: A layered construction's thermal resistance.
432
+ 0.0: If invalid input (see logs).
433
+
434
+ """
435
+ mth = "osut.rsi"
436
+ cl = openstudio.model.LayeredConstruction
437
+
438
+ if not isinstance(lc, cl):
439
+ return oslg.mismatch("lc", lc, cl, mth, CN.DBG, 0.0)
440
+
441
+ try:
442
+ film = float(film)
443
+ except:
444
+ return oslg.mismatch("film", film, float, mth, CN.DBG, 0.0)
445
+
446
+ try:
447
+ t = float(t)
448
+ except:
449
+ return oslg.mismatch("temp K", t, float, mth, CN.DBG, 0.0)
450
+
451
+ t += 273.0 # °C to K
452
+
453
+ if t < 0:
454
+ return oslg.negative("temp K", mth, CN.ERR, 0.0)
455
+ if film < 0:
456
+ return oslg.negative("film", mth, CN.ERR, 0.0)
457
+
458
+ rsi = film
459
+
460
+ for m in lc.layers():
461
+ if m.to_SimpleGlazing():
462
+ return 1 / m.to_SimpleGlazing().get().uFactor()
463
+ elif m.to_StandardGlazing():
464
+ rsi += m.to_StandardGlazing().get().thermalResistance()
465
+ elif m.to_RefractionExtinctionGlazing():
466
+ rsi += m.to_RefractionExtinctionGlazing().get().thermalResistance()
467
+ elif m.to_Gas():
468
+ rsi += m.to_Gas().get().getThermalResistance(t)
469
+ elif m.to_GasMixture():
470
+ rsi += m.to_GasMixture().get().getThermalResistance(t)
471
+
472
+ # Opaque materials next.
473
+ if m.to_StandardOpaqueMaterial():
474
+ rsi += m.to_StandardOpaqueMaterial().get().thermalResistance()
475
+ elif m.to_MasslessOpaqueMaterial():
476
+ rsi += m.to_MasslessOpaqueMaterial()
477
+ elif m.to_RoofVegetation():
478
+ rsi += m.to_RoofVegetation().get().thermalResistance()
479
+ elif m.to_AirGap():
480
+ rsi += m.to_AirGap().get().thermalResistance()
481
+
482
+ return rsi
483
+
484
+
485
+ def insulatingLayer(lc=None) -> dict:
486
+ """Identifies a layered construction's (opaque) insulating layer.
487
+
488
+ Args:
489
+ lc (openStudio.model.LayeredConstruction):
490
+ an OpenStudio layered construction
491
+
492
+ Returns:
493
+ An insulating-layer dictionary:
494
+ - "index" (int): construction's insulating layer index [0, n layers)
495
+ - "type" (str): layer material type ("standard" or "massless")
496
+ - "r" (float): material thermal resistance in m2•K/W.
497
+ If unsuccessful, dictionary is voided as follows (see logs):
498
+ "index": None
499
+ "type": None
500
+ "r": 0.0
501
+
502
+ """
503
+ mth = "osut.insulatingLayer"
504
+ cl = openstudio.model.LayeredConstruction
505
+ res = dict(index=None, type=None, r=0.0)
506
+ i = 0 # iterator
507
+
508
+ if not isinstance(lc, cl):
509
+ return oslg.mismatch("lc", lc, cl, mth, CN.DBG, res)
510
+
511
+ for l in lc.layers():
512
+ if l.to_MasslessOpaqueMaterial():
513
+ l = l.to_MasslessOpaqueMaterial().get()
514
+
515
+ if l.thermalResistance() < 0.001 or l.thermalResistance() < res["r"]:
516
+ i += 1
517
+ continue
518
+ else:
519
+ res["r" ] = m.thermalResistance()
520
+ res["index"] = i
521
+ res["type" ] = "massless"
522
+
523
+ if l.to_StandardOpaqueMaterial():
524
+ l = l.to_StandardOpaqueMaterial().get()
525
+ k = l.thermalConductivity()
526
+ d = l.thickness()
527
+
528
+ if (d < 0.003) or (k > 3.0) or (d / k < res["r"]):
529
+ i += 1
530
+ continue
531
+ else:
532
+ res["r" ] = d / k
533
+ res["index"] = i
534
+ res["type" ] = "standard"
535
+
536
+ i += 1
537
+
538
+ return res
539
+
540
+
541
+ def isUniqueMaterial(m=None) -> bool:
542
+ """Validates whether a material is both uniquely reserved to a single
543
+ layered construction in a model, and referenced only once in the
544
+ construction. Limited to 'standard' or 'massless' materials.
545
+
546
+ Args:
547
+ m (openStudio.model.OpaqueMaterial):
548
+ an OpenStudio opaque material
549
+
550
+ Returns:
551
+ True: Whether material is unique.
552
+ False: If material is missing.
553
+
554
+ """
555
+ mth = "osut.isUniqueMaterial"
556
+ cl = openstudio.model.OpaqueMaterial
557
+
558
+ if not isinstance(m, cl):
559
+ return oslg.mismatch("material", m, cl, mth, CN.DBG, False)
560
+
561
+ num = 0
562
+ lcs = m.model().getLayeredConstructions()
563
+
564
+ if m.to_MasslessOpaqueMaterial():
565
+ m = m.to_MasslessOpaqueMaterial().get()
566
+
567
+ for lc in lcs:
568
+ num += lc.getLayerIndices(m).size()
569
+
570
+ if num == 1: return True
571
+
572
+ if m.to_StandardOpaqueMaterial():
573
+ m = m.to_StandardOpaqueMaterial().get()
574
+
575
+ for lc in lcs:
576
+ num += lc.getLayerIndices(m).size()
577
+
578
+ if num == 1: return True
579
+
580
+ return False
581
+
582
+
583
+ def assignUniqueMaterial(lc=None, index=None) -> bool:
584
+ """Sets a layered construction material as unique. Solution similar to
585
+ OpenStudio::Model::LayeredConstruction's 'ensureUniqueLayers', yet limited
586
+ here to a single indexed OpenStudio material, typically the principal
587
+ insulating material. Returns true if the indexed material is already unique.
588
+ Limited to 'standard' or 'massless' materials.
589
+
590
+ Args:
591
+ lc (OpenStudio::Model::LayeredConstruction):
592
+ A construction.
593
+ index:
594
+ The construction layer index of the material.
595
+
596
+ Returns:
597
+ True: If assigned as unique.
598
+ None: If invalid inputs (see logs).
599
+
600
+ """
601
+ mth = "osut.assignUniqueMaterial"
602
+ cl = openstudio.model.LayeredConstruction
603
+
604
+ if not isinstance(lc, cl):
605
+ return oslg.mismatch("construction", lc, cl, mth, CN.DBG, False)
606
+
607
+ try:
608
+ index = int(index)
609
+ except:
610
+ return oslg.mismatch("index", index, int, mth, CN.DBG, False)
611
+
612
+ if index < 0 or index > lc.numLayers() - 1:
613
+ return oslg.invalid("index", mth, 0, CN.DBG, False)
614
+
615
+ m = lc.getLayer(index)
616
+
617
+ if m.to_MasslessOpaqueMaterial():
618
+ m = m.to_MasslessOpaqueMaterial().get()
619
+
620
+ if isUniqueMaterial(m): return True
621
+
622
+ mat = m.clone(m.model()).to_MasslessOpaqueMaterial().get()
623
+ return lc.setLayer(index, mat)
624
+
625
+ if m.to_StandardOpaqueMaterial():
626
+ m = m.to_StandardOpaqueMaterial().get()
627
+
628
+ if isUniqueMaterial(m): return True
629
+
630
+ mat = m.clone(m.model()).to_StandardOpaqueMaterial().get()
631
+
632
+ return False
633
+
634
+
635
+ def resetUo(lc=None, film=None, index=None, uo=None, uniq=False) -> float:
636
+ """Resets a construction's Uo factor by adjusting its insulating layer
637
+ thermal conductivity, then if needed its thickness (or its RSi value if
638
+ massless). Unless material uniquness is requested, a matching material is
639
+ recovered instead of instantiating a new one. The latter is renamed
640
+ according to its adjusted conductivity/thickness (or RSi value).
641
+
642
+ Args:
643
+ lc (OpenStudio::Model::LayeredConstruction):
644
+ A construction.
645
+ film (float):
646
+ The construction air film resistance.
647
+ index (int):
648
+ The insulating layer's array index.
649
+ uo (float):
650
+ Desired Uo factor (with air film resistance).
651
+ uniq (bool):
652
+ Whether to enforce material uniqueness.
653
+
654
+ Returns:
655
+ float: New layer RSi [CN.RMIN, CN.RMAX].
656
+ 0.0: If invalid inputs (see logs).
657
+
658
+ """
659
+ mth = "osut.resetUo"
660
+ r = 0.0 # thermal resistance of new material
661
+ cl = openstudio.model.LayeredConstruction
662
+
663
+ if not isinstance(lc, cl):
664
+ return oslg.mismatch("construction", lc, cl, mth, CN.DBG, r)
665
+ if not isinstance(uniq, bool):
666
+ uniq = False
667
+
668
+ try:
669
+ film = float(film)
670
+ except:
671
+ return oslg.mismatch("film", film, float, mth, CN.DBG, r)
672
+
673
+ try:
674
+ index = int(index)
675
+ except:
676
+ return oslg.mismatch("index", index, int, mth, CN.DBG, r)
677
+
678
+ try:
679
+ uo = float(uo)
680
+ except:
681
+ return oslg.mismatch("uo", uo, float, mth, CN.DBG, r)
682
+
683
+ if film < 0:
684
+ return oslg.negative("film", mth, CN.DBG, r)
685
+ if index < 0 or index > lc.numLayers() - 1:
686
+ return oslg.invalid("index", mth, 3, CN.DBG, r)
687
+ if uo < CN.UMIN or uo > CN.UMAX:
688
+ uo = clamp(uo, CN.UMIN, CN.UMAX)
689
+ msg = "Resetting Uo %s to %.3f (%s)" % (lc.nameString(), uo, mth)
690
+ oslg.log(CN.WRN, msg)
691
+
692
+ r0 = rsi(lc, film) # current construction RSi value
693
+ ro = 1 / uo # desired construction RSi value
694
+ dR = ro - r0 # desired increase in construction RSi
695
+ m = lc.getLayer(index)
696
+
697
+ if m.to_MasslessOpaqueMaterial():
698
+ m = m.to_MasslessOpaqueMaterial().get()
699
+ r = m.thermalResistance()
700
+ if round(abs(dR), 2) == 0.00: return r
701
+
702
+ r = clamp(r + dR, RMIN, RMAX)
703
+ id = "OSut:RSi%.2f" % r
704
+ mt = lc.model().getMasslessOpaqueMaterialByName(id)
705
+
706
+ # Existing material?
707
+ if mt:
708
+ mt = mt.get()
709
+
710
+ if round(r, 2) == round(mt.thermalResistance(), 2) and uniq == False:
711
+ lc.setLayer(index, mt)
712
+ return r
713
+
714
+ mt = m.clone(m.model()).to_MasslessOpaqueMaterial().get()
715
+ mt.setName(id)
716
+
717
+ if not mt.setThermalResistance(r):
718
+ oslg.log(CN.WRN, "Failed to reset %s: RSi%.2f (%s)" % (id, r, mth))
719
+ return 0.0
720
+
721
+ lc.setLayer(index, mt)
722
+ return r
723
+
724
+ if m.to_StandardOpaqueMaterial():
725
+ m = m.to_StandardOpaqueMaterial().get()
726
+ r = m.thickness() / m.conductivity()
727
+ if round(abs(dR), 2) == 0.00: return r
728
+
729
+ k = clamp(m.thickness() / (r + dR), CN.KMIN, CN.KMAX)
730
+ d = clamp(k * (r + dR), CN.DMIN, CN.DMAX)
731
+ r = d / k
732
+ id = "OSut:K%.3f:%03d" % (k, d*1000)
733
+ mt = lc.model().getStandardOpaqueMaterialByName(id)
734
+
735
+ # Existing material?
736
+ if mt:
737
+ mt = mt.get()
738
+ rt = mt.thickness() / mt.conductivity()
739
+
740
+ if round(r, 2) == round(rt, 2) and uniq == False:
741
+ lc.setlayer(index, mt)
742
+ return r
743
+
744
+ mt = m.clone(m.model()).to_StandardOpaqueMaterial().get()
745
+ mt.setName(id)
746
+
747
+ if not mt.setThermalConductivity(k):
748
+ oslg.log(CN.WRN, "Failed to reset %s: K%.3f (%s)" % (id, k, mth))
749
+ return 0.0
750
+
751
+ if not mt.setThickness(d):
752
+ d = int(d*1000)
753
+ oslg.log(CN.WRN, "Failed to reset %s: %dmm (%s)" % (id, d, mth))
754
+ return 0.0
755
+
756
+ lc.setLayer(index, mt)
757
+ return r
758
+
759
+ return 0.0
760
+
761
+
303
762
  def genConstruction(model=None, specs=dict()):
304
763
  """Generates an OpenStudio multilayered construction, + materials if needed.
305
764
 
@@ -345,12 +804,10 @@ def genConstruction(model=None, specs=dict()):
345
804
  except:
346
805
  return oslg.mismatch(id + " Uo", u, float, mth, CN.ERR)
347
806
 
348
- if u < 0:
349
- return oslg.negative(id + " Uo", mth, CN.ERR)
350
- if round(u, 2) == 0:
351
- return oslg.zero(id + " Uo", mth, CN.ERR)
352
- if u > 5.678:
353
- return oslg.invalid(id + " Uo (> 5.678)", mth, 2, CN.ERR)
807
+ if u < CN.UMIN or u > CN.UMAX:
808
+ u0 = u
809
+ u = clamp(u0, CN.UMIN, CN.UMAX)
810
+ oslg.log(CN.ERR, "Resetting Uo %.3f to %.3f (%s)" % (u0, u, mth))
354
811
 
355
812
  # Optional specs. Log/reset if invalid.
356
813
  if "clad" not in specs: specs["clad" ] = "light" # exterior
@@ -643,7 +1100,7 @@ def genConstruction(model=None, specs=dict()):
643
1100
  if u and not a["glazing"]:
644
1101
  ro = 1 / u - flm
645
1102
 
646
- if ro > 0:
1103
+ if ro > CN.RMIN:
647
1104
  if specs["type"] == "door": # 1x layer, adjust conductivity
648
1105
  layer = c.getLayer(0).to_StandardOpaqueMaterial()
649
1106
 
@@ -653,38 +1110,14 @@ def genConstruction(model=None, specs=dict()):
653
1110
  layer = layer.get()
654
1111
  k = layer.thickness() / ro
655
1112
  layer.setConductivity(k)
656
-
657
- else: # multiple layers, adjust insulating layer thickness
1113
+ else: # multiple layers
658
1114
  lyr = insulatingLayer(c)
659
1115
 
660
- if not lyr["index"] or not lyr["type"] or not lyr["r"]:
661
- return oslg.invalid(id + " construction", mth, 0)
1116
+ if not lyr["index"] or not lyr["type"] or round(lyr["r"], 2) == 0:
1117
+ return oslg.invalid(ide + " construction", mth, 0)
662
1118
 
663
1119
  index = lyr["index"]
664
- layer = c.getLayer(index).to_StandardOpaqueMaterial()
665
-
666
- if not layer:
667
- return oslg.invalid(id + " material %d" % index, mth, 0)
668
-
669
- layer = layer.get()
670
- k = layer.conductivity()
671
- d = (ro - rsi(c) + lyr["r"]) * k
672
-
673
- if d < 0.03:
674
- m = id + " adjusted material thickness"
675
- return oslg.invalid(m, mth, 0)
676
-
677
- nom = re.sub(r'[^a-zA-Z]', '', layer.nameString())
678
- nom = re.sub(r'OSut', '', nom)
679
- nom = "OSut." + nom + ".%03d" % int(d * 1000)
680
-
681
- if model.getStandardOpaqueMaterialByName(nom):
682
- omat = model.getStandardOpaqueMaterialByName(nom).get()
683
- c.setLayer(index, omat)
684
- else:
685
- layer.setName(nom)
686
- layer.setThickness(d)
687
-
1120
+ resetUo(c, flm, index, u)
688
1121
  return c
689
1122
 
690
1123
 
@@ -874,7 +1307,7 @@ def genMass(sps=None, ratio=2.0) -> bool:
874
1307
  return True
875
1308
 
876
1309
 
877
- def holdsConstruction(cset=None, base=None, gr=False, ex=False, type=""):
1310
+ def holdsConstruction(cset=None, base=None, gr=False, ex=False, type="") -> bool:
878
1311
  """Validates whether a default construction set holds a base construction.
879
1312
 
880
1313
  Args:
@@ -886,7 +1319,7 @@ def holdsConstruction(cset=None, base=None, gr=False, ex=False, type=""):
886
1319
  Whether ground-facing surface.
887
1320
  ex (bool):
888
1321
  Whether exterior-facing surface.
889
- type:
1322
+ type (str):
890
1323
  An OpenStudio surface (or sub surface) type (e.g. "Wall").
891
1324
 
892
1325
  Returns:
@@ -1012,271 +1445,71 @@ def defaultConstructionSet(s=None):
1012
1445
 
1013
1446
  ground = True if s.isGroundSurface() else False
1014
1447
  exterior = True if bnd == "outdoors" else False
1448
+ adjacent = None
1449
+ aspace = None
1450
+ typ = None
1451
+
1452
+ if s.adjacentSurface():
1453
+ adjacent = s.adjacentSurface().get()
1454
+ typ = adjacent.surfaceType()
1455
+
1456
+ if adjacent.space():
1457
+ aspace = adjacent.space().get()
1015
1458
 
1016
1459
  if space.defaultConstructionSet():
1017
- cset = space.defaultConstructionSet().get()
1460
+ set = space.defaultConstructionSet().get()
1018
1461
 
1019
- if holdsConstruction(cset, base, ground, exterior, type): return cset
1462
+ if holdsConstruction(set, base, ground, exterior, type): return set
1463
+ elif aspace:
1464
+ if aspace.defaultConstructionSet():
1465
+ set = aspace.defaultConstructionSet().get()
1466
+
1467
+ if holdsConstruction(set, base, ground, exterior, typ): return set
1020
1468
 
1021
1469
  if space.spaceType():
1022
1470
  spacetype = space.spaceType().get()
1023
1471
 
1024
1472
  if spacetype.defaultConstructionSet():
1025
- cset = spacetype.defaultConstructionSet().get()
1473
+ set = spacetype.defaultConstructionSet().get()
1474
+
1475
+ if holdsConstruction(set, base, ground, exterior, type): return set
1476
+
1477
+ if aspace and aspace.spaceType():
1478
+ spacetype = aspace.spaceType().get()
1479
+
1480
+ if spacetype.defaultConstructionSet():
1481
+ set = spacetype.defaultConstructionSet().get()
1026
1482
 
1027
- if holdsConstruction(cset, base, ground, exterior, type):
1028
- return cset
1483
+ if holdsConstruction(set, base, ground, exterior, typ): return set
1029
1484
 
1030
1485
  if space.buildingStory():
1031
1486
  story = space.buildingStory().get()
1032
1487
 
1033
1488
  if story.defaultConstructionSet():
1034
- cset = story.defaultConstructionSet().get()
1489
+ set = story.defaultConstructionSet().get()
1490
+
1491
+ if holdsConstruction(set, base, ground, exterior, type): return set
1035
1492
 
1036
- if holdsConstruction(cset, base, ground, exterior, type):
1037
- return cset
1493
+ if aspace and aspace.buildingStory():
1494
+ story = aspace.buildingStory().get()
1038
1495
 
1496
+ if story.defaultConstructionSet():
1497
+ set = story.defaultConstructionSet().get()
1498
+
1499
+ if holdsConstruction(set, base, ground, exterior, typ):
1500
+ return set
1039
1501
 
1040
1502
  building = mdl.getBuilding()
1041
1503
 
1042
1504
  if building.defaultConstructionSet():
1043
- cset = building.defaultConstructionSet().get()
1505
+ set = building.defaultConstructionSet().get()
1044
1506
 
1045
- if holdsConstruction(cset, base, ground, exterior, type):
1046
- return cset
1507
+ if holdsConstruction(set, base, ground, exterior, type):
1508
+ return set
1047
1509
 
1048
1510
  return None
1049
1511
 
1050
1512
 
1051
- def areStandardOpaqueLayers(lc=None) -> bool:
1052
- """Validates if every material in a layered construction is standard/opaque.
1053
-
1054
- Args:
1055
- lc (openstudio.model.LayeredConstruction):
1056
- an OpenStudio layered construction
1057
-
1058
- Returns:
1059
- True: If all layers are valid (standard & opaque).
1060
- False: If invalid inputs (see logs).
1061
-
1062
- """
1063
- mth = "osut.areStandardOpaqueLayers"
1064
- cl = openstudio.model.LayeredConstruction
1065
-
1066
- if not isinstance(lc, cl):
1067
- return oslg.mismatch("lc", lc, cl, mth, CN.DBG, 0.0)
1068
-
1069
- for m in lc.layers():
1070
- if not m.to_StandardOpaqueMaterial(): return False
1071
-
1072
- return True
1073
-
1074
-
1075
- def thickness(lc=None) -> float:
1076
- """Returns total (standard opaque) layered construction thickness (m).
1077
-
1078
- Args:
1079
- lc (openstudio.model.LayeredConstruction):
1080
- an OpenStudio layered construction
1081
-
1082
- Returns:
1083
- float: A standard opaque construction thickness.
1084
- 0.0: If invalid inputs (see logs).
1085
-
1086
- """
1087
- mth = "osut.thickness"
1088
- cl = openstudio.model.LayeredConstruction
1089
- d = 0.0
1090
-
1091
- if not isinstance(lc, cl):
1092
- return oslg.mismatch("lc", lc, cl, mth, CN.DBG, 0.0)
1093
- if not areStandardOpaqueLayers(lc):
1094
- oslg.log(CN.ERR, "holding non-StandardOpaqueMaterial(s) %s" % mth)
1095
- return d
1096
-
1097
- for m in lc.layers(): d += m.thickness()
1098
-
1099
- return d
1100
-
1101
-
1102
- def glazingAirFilmRSi(usi=5.85) -> float:
1103
- """Returns total air film resistance of a fenestrated construction (m2•K/W).
1104
-
1105
- Args:
1106
- usi (float):
1107
- A fenestrated construction's U-factor (W/m2•K).
1108
-
1109
- Returns:
1110
- float: Total air film resistances.
1111
- 0.1216: If invalid input (see logs).
1112
-
1113
- """
1114
- # The sum of thermal resistances of calculated exterior and interior film
1115
- # coefficients under standard winter conditions are taken from:
1116
- #
1117
- # https://bigladdersoftware.com/epx/docs/9-6/engineering-reference/
1118
- # window-calculation-module.html#simple-window-model
1119
- #
1120
- # These remain acceptable approximations for flat windows, yet likely
1121
- # unsuitable for subsurfaces with curved or projecting shapes like domed
1122
- # skylights. The solution here is considered an adequate fix for reporting,
1123
- # awaiting eventual OpenStudio (and EnergyPlus) upgrades to report NFRC 100
1124
- # (or ISO) air film resistances under standard winter conditions.
1125
- #
1126
- # For U-factors above 8.0 W/m2•K (or invalid input), the function returns
1127
- # 0.1216 m2•K/W, which corresponds to a construction with a single glass
1128
- # layer thickness of 2mm & k = ~0.6 W/m.K.
1129
- #
1130
- # The EnergyPlus Engineering calculations were designed for vertical
1131
- # windows, not for horizontal, slanted or domed surfaces - use with caution.
1132
- mth = "osut.glazingAirFilmRSi"
1133
- val = 0.1216
1134
-
1135
- try:
1136
- usi = float(usi)
1137
- except:
1138
- return oslg.mismatch("usi", usi, float, mth, CN.DBG, val)
1139
-
1140
- if usi > 8.0:
1141
- return oslg.invalid("usi", mth, 1, CN.WRN, val)
1142
- elif usi < 0:
1143
- return oslg.negative("usi", mth, CN.WRN, val)
1144
- elif abs(usi) < CN.TOL:
1145
- return oslg.zero("usi", mth, CN.WRN, val)
1146
-
1147
- rsi = 1 / (0.025342 * usi + 29.163853) # exterior film, next interior film
1148
-
1149
- if usi < 5.85:
1150
- return rsi + 1 / (0.359073 * math.log(usi) + 6.949915)
1151
-
1152
- return rsi + 1 / (1.788041 * usi - 2.886625)
1153
-
1154
-
1155
- def rsi(lc=None, film=0.0, t=0.0) -> float:
1156
- """Returns a construction's 'standard calc' thermal resistance (m2•K/W),
1157
- which includes air film resistances. It excludes insulating effects of
1158
- shades, screens, etc. in the case of fenestrated constructions. Adapted
1159
- from BTAP's 'Material' Module "get_conductance" (P. Lopez).
1160
-
1161
- Args:
1162
- lc (openstudio.model.LayeredConstruction):
1163
- an OpenStudio layered construction
1164
- film (float):
1165
- thermal resistance of surface air films (m2•K/W)
1166
- t (float):
1167
- gas temperature (°C) (optional)
1168
-
1169
- Returns:
1170
- float: A layered construction's thermal resistance.
1171
- 0.0: If invalid input (see logs).
1172
-
1173
- """
1174
- mth = "osut.rsi"
1175
- cl = openstudio.model.LayeredConstruction
1176
-
1177
- if not isinstance(lc, cl):
1178
- return oslg.mismatch("lc", lc, cl, mth, CN.DBG, 0.0)
1179
-
1180
- try:
1181
- film = float(film)
1182
- except:
1183
- return oslg.mismatch("film", film, float, mth, CN.DBG, 0.0)
1184
-
1185
- try:
1186
- t = float(t)
1187
- except:
1188
- return oslg.mismatch("temp K", t, float, mth, CN.DBG, 0.0)
1189
-
1190
- t += 273.0 # °C to K
1191
-
1192
- if t < 0:
1193
- return oslg.negative("temp K", mth, CN.ERR, 0.0)
1194
- if film < 0:
1195
- return oslg.negative("film", mth, CN.ERR, 0.0)
1196
-
1197
- rsi = film
1198
-
1199
- for m in lc.layers():
1200
- if m.to_SimpleGlazing():
1201
- return 1 / m.to_SimpleGlazing().get().uFactor()
1202
- elif m.to_StandardGlazing():
1203
- rsi += m.to_StandardGlazing().get().thermalResistance()
1204
- elif m.to_RefractionExtinctionGlazing():
1205
- rsi += m.to_RefractionExtinctionGlazing().get().thermalResistance()
1206
- elif m.to_Gas():
1207
- rsi += m.to_Gas().get().getThermalResistance(t)
1208
- elif m.to_GasMixture():
1209
- rsi += m.to_GasMixture().get().getThermalResistance(t)
1210
-
1211
- # Opaque materials next.
1212
- if m.to_StandardOpaqueMaterial():
1213
- rsi += m.to_StandardOpaqueMaterial().get().thermalResistance()
1214
- elif m.to_MasslessOpaqueMaterial():
1215
- rsi += m.to_MasslessOpaqueMaterial()
1216
- elif m.to_RoofVegetation():
1217
- rsi += m.to_RoofVegetation().get().thermalResistance()
1218
- elif m.to_AirGap():
1219
- rsi += m.to_AirGap().get().thermalResistance()
1220
-
1221
- return rsi
1222
-
1223
-
1224
- def insulatingLayer(lc=None) -> dict:
1225
- """Identifies a layered construction's (opaque) insulating layer.
1226
-
1227
- Args:
1228
- lc (openStudio.model.LayeredConstruction):
1229
- an OpenStudio layered construction
1230
-
1231
- Returns:
1232
- An insulating-layer dictionary:
1233
- - "index" (int): construction's insulating layer index [0, n layers)
1234
- - "type" (str): layer material type ("standard" or "massless")
1235
- - "r" (float): material thermal resistance in m2•K/W.
1236
- If unsuccessful, dictionary is voided as follows (see logs):
1237
- "index": None
1238
- "type": None
1239
- "r": 0.0
1240
-
1241
- """
1242
- mth = "osut.insulatingLayer"
1243
- cl = openstudio.model.LayeredConstruction
1244
- res = dict(index=None, type=None, r=0.0)
1245
- i = 0 # iterator
1246
-
1247
- if not isinstance(lc, cl):
1248
- return oslg.mismatch("lc", lc, cl, mth, CN.DBG, res)
1249
-
1250
- for l in lc.layers():
1251
- if l.to_MasslessOpaqueMaterial():
1252
- l = l.to_MasslessOpaqueMaterial().get()
1253
-
1254
- if l.thermalResistance() < 0.001 or l.thermalResistance() < res["r"]:
1255
- i += 1
1256
- continue
1257
- else:
1258
- res["r" ] = m.thermalResistance()
1259
- res["index"] = i
1260
- res["type" ] = "massless"
1261
-
1262
- if l.to_StandardOpaqueMaterial():
1263
- l = l.to_StandardOpaqueMaterial().get()
1264
- k = l.thermalConductivity()
1265
- d = l.thickness()
1266
-
1267
- if (d < 0.003) or (k > 3.0) or (d / k < res["r"]):
1268
- i += 1
1269
- continue
1270
- else:
1271
- res["r" ] = d / k
1272
- res["index"] = i
1273
- res["type" ] = "standard"
1274
-
1275
- i += 1
1276
-
1277
- return res
1278
-
1279
-
1280
1513
  def areSpandrels(surfaces=None) -> bool:
1281
1514
  """Validates whether one or more opaque surface(s) can be considered as
1282
1515
  curtain wall (or similar technology) spandrels, regardless of construction
@@ -1289,6 +1522,7 @@ def areSpandrels(surfaces=None) -> bool:
1289
1522
  Returns:
1290
1523
  bool: Whether surface(s) can be considered 'spandrels'.
1291
1524
  False: If invalid input (see logs).
1525
+
1292
1526
  """
1293
1527
  mth = "osut.areSpandrels"
1294
1528
  cl = openstudio.model.Surface
@@ -1384,7 +1618,7 @@ def isFenestrated(s=None) -> bool:
1384
1618
  # - UNCONDITIONED space: an ENCLOSED space that is NOT a conditioned
1385
1619
  # space or a SEMIHEATED space (see above).
1386
1620
  #
1387
- # NOTE: Crawlspaces, attics, and parking garages with natural or
1621
+ # Note: Crawlspaces, attics, and parking garages with natural or
1388
1622
  # mechanical ventilation are considered UNENCLOSED spaces.
1389
1623
  #
1390
1624
  # 2.3.3 Modeling Requirements: surfaces adjacent to UNENCLOSED spaces
@@ -5380,7 +5614,7 @@ def genAnchors(s=None, sset=[], tag="box") -> int:
5380
5614
  # Validate individual subsets. Purge surface-specific leader line anchors.
5381
5615
  for i, st in enumerate(sset):
5382
5616
  str1 = ide + "subset %d" % (i+1)
5383
- str2 = str1 + " %s" % str(tag)
5617
+ str2 = str1 + " %s" % oslg.trim(tag)
5384
5618
 
5385
5619
  if not isinstance(st, dict):
5386
5620
  return oslg.mismatch(str1, st, dict, mth, CN.DBG, n)
@@ -5559,7 +5793,7 @@ def genExtendedVertices(s=None, sset=[], tag="vtx") -> openstudio.Point3dVector:
5559
5793
  # Validate individual subsets.
5560
5794
  for i, st in enumerate(sset):
5561
5795
  str1 = ide + "subset %d" % (i+1)
5562
- str2 = str1 + " %s" % str(tag)
5796
+ str2 = str1 + " %s" % oslg.trim(tag)
5563
5797
 
5564
5798
  if not isinstance(st, dict):
5565
5799
  return oslg.mismatch(str1, st, dict, mth, CN.DBG, a)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: osut
3
- Version: 0.7.0
3
+ Version: 0.8.1
4
4
  Summary: OpenStudio SDK utilities for Python
5
5
  Author-email: Denis Bourgeois <denis@rd2.ca>
6
6
  Maintainer-email: Denis Bourgeois <denis@rd2.ca>
@@ -19,7 +19,7 @@ Requires-Dist: oslg
19
19
  Dynamic: license-file
20
20
 
21
21
  # pyOSut
22
- Python implementation of the _OSut_ Ruby gem for the OpenStudio SDK.
22
+ Python implementation of the _OSut_ Ruby gem for the OpenStudio SDK.
23
23
 
24
24
  - PyPi [package](https://pypi.org/project/osut/)
25
25
  - Ruby [gem](https://rubygems.org/gems/osut)
@@ -45,6 +45,6 @@ To import the _OSut_ module in a Python project:
45
45
 
46
46
  ____
47
47
 
48
- To run the _OSut_ unit tests on a `git clone` of the repo:
48
+ To run the _OSut_ unit tests on a `git clone` of the repo:
49
49
 
50
50
  `python -m unittest`
@@ -0,0 +1,7 @@
1
+ osut/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ osut/osut.py,sha256=g-PHn4Z5WgNWJKBNlzDuX8bX2yvxs3AYWnKc_uMOQkU,310276
3
+ osut-0.8.1.dist-info/licenses/LICENSE,sha256=1fpl5h5cQqIU55E156I1kBIko9NJjDmEw3e-ysLhg3Y,1495
4
+ osut-0.8.1.dist-info/METADATA,sha256=bDeF4GXqJQ3gYtvKLlG_NV-X8ItqSlkdh-tgVLa0pag,1252
5
+ osut-0.8.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
6
+ osut-0.8.1.dist-info/top_level.txt,sha256=elxZoPwvGd11mNFvZvnG07mjsDiiiiU2VwmzXjnSWT4,5
7
+ osut-0.8.1.dist-info/RECORD,,
@@ -1,6 +1,6 @@
1
1
  BSD 3-Clause License
2
2
 
3
- Copyright (c) 2022-2025, rd2
3
+ Copyright (c) 2022-2026, rd2
4
4
 
5
5
  Redistribution and use in source and binary forms, with or without
6
6
  modification, are permitted provided that the following conditions are met:
@@ -1,7 +0,0 @@
1
- osut/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
- osut/osut.py,sha256=YyaK3ft1WRnPrAGkNGeTCA48ya_TapHccQkAmLm20M4,302755
3
- osut-0.7.0.dist-info/licenses/LICENSE,sha256=Ag_zDZp4XtiEQWfCwuPk25nVa9gsNhJ3SIx2Ahh1I6c,1495
4
- osut-0.7.0.dist-info/METADATA,sha256=6l48aPzuzXNVJXckxyvtEuq5Sl7g2M5GSAT2CSm8kzA,1254
5
- osut-0.7.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
6
- osut-0.7.0.dist-info/top_level.txt,sha256=elxZoPwvGd11mNFvZvnG07mjsDiiiiU2VwmzXjnSWT4,5
7
- osut-0.7.0.dist-info/RECORD,,
File without changes