From e1f2b23e947b1b74867a4f93194a4b475648a27b Mon Sep 17 00:00:00 2001 From: brgix Date: Thu, 1 Jan 2026 09:25:36 -0500 Subject: [PATCH 1/4] Upgrades layered construction constants/methods --- LICENSE | 2 +- README.md | 4 +- pyproject.toml | 2 +- src/osut/osut.py | 812 +++++++++++++++++++++++++++++---------------- tests/test_osut.py | 31 +- 5 files changed, 542 insertions(+), 309 deletions(-) diff --git a/LICENSE b/LICENSE index 86fca3d..ee4bc4a 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ BSD 3-Clause License -Copyright (c) 2022-2025, rd2 +Copyright (c) 2022-2026, rd2 Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: diff --git a/README.md b/README.md index 7d7249d..9298adf 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # pyOSut -Python implementation of the _OSut_ Ruby gem for the OpenStudio SDK. +Python implementation of the _OSut_ Ruby gem for the OpenStudio SDK. - PyPi [package](https://pypi.org/project/osut/) - Ruby [gem](https://rubygems.org/gems/osut) @@ -25,6 +25,6 @@ To import the _OSut_ module in a Python project: ____ -To run the _OSut_ unit tests on a `git clone` of the repo: +To run the _OSut_ unit tests on a `git clone` of the repo: `python -m unittest` diff --git a/pyproject.toml b/pyproject.toml index dd69ce3..6de4827 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "osut" -version = "0.7.0" +version = "0.8.0" description = "OpenStudio SDK utilities for Python" readme = "README.md" requires-python = ">=3.2" diff --git a/src/osut/osut.py b/src/osut/osut.py index 36db366..9c1cedb 100644 --- a/src/osut/osut.py +++ b/src/osut/osut.py @@ -1,6 +1,6 @@ # BSD 3-Clause License # -# Copyright (c) 2022-2025, rd2 +# Copyright (c) 2022-2026, rd2 # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: @@ -36,15 +36,24 @@ @dataclass(frozen=True) class _CN: - DBG = oslg.CN.DEBUG - INF = oslg.CN.INFO - WRN = oslg.CN.WARN - ERR = oslg.CN.ERROR - FTL = oslg.CN.FATAL - TOL = 0.01 # default distance tolerance (m) - TOL2 = TOL * TOL # default area tolerance (m2) - HEAD = 2.032 # standard 80" door - SILL = 0.762 # standard 30" window sill + DBG = oslg.CN.DEBUG # see github.com/rd2/pyOSlg + INF = oslg.CN.INFO # see github.com/rd2/pyOSlg + WRN = oslg.CN.WARN # see github.com/rd2/pyOSlg + ERR = oslg.CN.ERROR # see github.com/rd2/pyOSlg + FTL = oslg.CN.FATAL # see github.com/rd2/pyOSlg + TOL = 0.01 # default distance tolerance (m) + TOL2 = TOL * TOL # default area tolerance (m2) + HEAD = 2.032 # standard 80" door + SILL = 0.762 # standard 30" window sill + NS = "nameString" # OpenStudio object identifier method + DMIN = 0.010 # min. material thickness + DMAX = 1.000 # max. material thickness + KMIN = 0.010 # min. material thermal conductivity + KMAX = 2.000 # max. material thermal conductivity + UMAX = KMAX / DMIN # material USi upper limit, 200.000 + UMIN = KMIN / DMAX # material USi lower limit, 0.010 + RMIN = 1.0 / UMAX # material RSi lower limit, 0.005 (or R-IP 0.03) + RMAX = 1.0 / UMIN # material RSi upper limit, 100.000 (or R-IP 567.80) CN = _CN() # General surface orientations (see 'facets' method). @@ -300,6 +309,456 @@ def clamp(value, minimum, maximum) -> float: return value +def areStandardOpaqueLayers(lc=None) -> bool: + """Validates if every material in a layered construction is standard/opaque. + + Args: + lc (openstudio.model.LayeredConstruction): + an OpenStudio layered construction + + Returns: + True: If all layers are valid (standard & opaque). + False: If invalid inputs (see logs). + + """ + mth = "osut.areStandardOpaqueLayers" + cl = openstudio.model.LayeredConstruction + + if not isinstance(lc, cl): + return oslg.mismatch("lc", lc, cl, mth, CN.DBG, 0.0) + + for m in lc.layers(): + if not m.to_StandardOpaqueMaterial(): return False + + return True + + +def thickness(lc=None) -> float: + """Returns total (standard opaque) layered construction thickness (m). + + Args: + lc (openstudio.model.LayeredConstruction): + an OpenStudio layered construction + + Returns: + float: A standard opaque construction thickness. + 0.0: If invalid inputs (see logs). + + """ + mth = "osut.thickness" + cl = openstudio.model.LayeredConstruction + d = 0.0 + + if not isinstance(lc, cl): + return oslg.mismatch("lc", lc, cl, mth, CN.DBG, 0.0) + if not areStandardOpaqueLayers(lc): + oslg.log(CN.ERR, "holding non-StandardOpaqueMaterial(s) %s" % mth) + return d + + for m in lc.layers(): d += m.thickness() + + return d + + +def glazingAirFilmRSi(usi=5.85) -> float: + """Returns total air film resistance of a fenestrated construction (m2•K/W). + + Args: + usi (float): + A fenestrated construction's U-factor (W/m2•K). + + Returns: + float: Total air film resistances. + 0.1216: If invalid input (see logs). + + """ + # The sum of thermal resistances of calculated exterior and interior film + # coefficients under standard winter conditions are taken from: + # + # https://bigladdersoftware.com/epx/docs/9-6/engineering-reference/ + # window-calculation-module.html#simple-window-model + # + # These remain acceptable approximations for flat windows, yet likely + # unsuitable for subsurfaces with curved or projecting shapes like domed + # skylights. The solution here is considered an adequate fix for reporting, + # awaiting eventual OpenStudio (and EnergyPlus) upgrades to report NFRC 100 + # (or ISO) air film resistances under standard winter conditions. + # + # For U-factors above 8.0 W/m2•K (or invalid input), the function returns + # 0.1216 m2•K/W, which corresponds to a construction with a single glass + # layer thickness of 2mm & k = ~0.6 W/m.K. + # + # The EnergyPlus Engineering calculations were designed for vertical + # windows, not for horizontal, slanted or domed surfaces - use with caution. + mth = "osut.glazingAirFilmRSi" + val = 0.1216 + + try: + usi = float(usi) + except: + return oslg.mismatch("usi", usi, float, mth, CN.DBG, val) + + if usi > 8.0: + return oslg.invalid("usi", mth, 1, CN.WRN, val) + elif usi < 0: + return oslg.negative("usi", mth, CN.WRN, val) + elif abs(usi) < CN.TOL: + return oslg.zero("usi", mth, CN.WRN, val) + + rsi = 1 / (0.025342 * usi + 29.163853) # exterior film, next interior film + + if usi < 5.85: + return rsi + 1 / (0.359073 * math.log(usi) + 6.949915) + + return rsi + 1 / (1.788041 * usi - 2.886625) + + +def rsi(lc=None, film=0.0, t=0.0) -> float: + """Returns a construction's 'standard calc' thermal resistance (m2•K/W), + which includes air film resistances. It excludes insulating effects of + shades, screens, etc. in the case of fenestrated constructions. Adapted + from BTAP's 'Material' Module "get_conductance" (P. Lopez). + + Args: + lc (openstudio.model.LayeredConstruction): + an OpenStudio layered construction + film (float): + thermal resistance of surface air films (m2•K/W) + t (float): + gas temperature (°C) (optional) + + Returns: + float: A layered construction's thermal resistance. + 0.0: If invalid input (see logs). + + """ + mth = "osut.rsi" + cl = openstudio.model.LayeredConstruction + + if not isinstance(lc, cl): + return oslg.mismatch("lc", lc, cl, mth, CN.DBG, 0.0) + + try: + film = float(film) + except: + return oslg.mismatch("film", film, float, mth, CN.DBG, 0.0) + + try: + t = float(t) + except: + return oslg.mismatch("temp K", t, float, mth, CN.DBG, 0.0) + + t += 273.0 # °C to K + + if t < 0: + return oslg.negative("temp K", mth, CN.ERR, 0.0) + if film < 0: + return oslg.negative("film", mth, CN.ERR, 0.0) + + rsi = film + + for m in lc.layers(): + if m.to_SimpleGlazing(): + return 1 / m.to_SimpleGlazing().get().uFactor() + elif m.to_StandardGlazing(): + rsi += m.to_StandardGlazing().get().thermalResistance() + elif m.to_RefractionExtinctionGlazing(): + rsi += m.to_RefractionExtinctionGlazing().get().thermalResistance() + elif m.to_Gas(): + rsi += m.to_Gas().get().getThermalResistance(t) + elif m.to_GasMixture(): + rsi += m.to_GasMixture().get().getThermalResistance(t) + + # Opaque materials next. + if m.to_StandardOpaqueMaterial(): + rsi += m.to_StandardOpaqueMaterial().get().thermalResistance() + elif m.to_MasslessOpaqueMaterial(): + rsi += m.to_MasslessOpaqueMaterial() + elif m.to_RoofVegetation(): + rsi += m.to_RoofVegetation().get().thermalResistance() + elif m.to_AirGap(): + rsi += m.to_AirGap().get().thermalResistance() + + return rsi + + +def insulatingLayer(lc=None) -> dict: + """Identifies a layered construction's (opaque) insulating layer. + + Args: + lc (openStudio.model.LayeredConstruction): + an OpenStudio layered construction + + Returns: + An insulating-layer dictionary: + - "index" (int): construction's insulating layer index [0, n layers) + - "type" (str): layer material type ("standard" or "massless") + - "r" (float): material thermal resistance in m2•K/W. + If unsuccessful, dictionary is voided as follows (see logs): + "index": None + "type": None + "r": 0.0 + + """ + mth = "osut.insulatingLayer" + cl = openstudio.model.LayeredConstruction + res = dict(index=None, type=None, r=0.0) + i = 0 # iterator + + if not isinstance(lc, cl): + return oslg.mismatch("lc", lc, cl, mth, CN.DBG, res) + + for l in lc.layers(): + if l.to_MasslessOpaqueMaterial(): + l = l.to_MasslessOpaqueMaterial().get() + + if l.thermalResistance() < 0.001 or l.thermalResistance() < res["r"]: + i += 1 + continue + else: + res["r" ] = m.thermalResistance() + res["index"] = i + res["type" ] = "massless" + + if l.to_StandardOpaqueMaterial(): + l = l.to_StandardOpaqueMaterial().get() + k = l.thermalConductivity() + d = l.thickness() + + if (d < 0.003) or (k > 3.0) or (d / k < res["r"]): + i += 1 + continue + else: + res["r" ] = d / k + res["index"] = i + res["type" ] = "standard" + + i += 1 + + return res + + +def isUniqueMaterial(m=None) -> bool: + """Validates whether a material is both uniquely reserved to a single + layered construction in a model, and referenced only once in the + construction. Limited to 'standard' or 'massless' materials. + + Args: + m (openStudio.model.OpaqueMaterial): + an OpenStudio opaque material + + Returns: + True: Whether material is unique. + False: If material is missing. + + """ + mth = "osut.isUniqueMaterial" + cl = openstudio.model.OpaqueMaterial + + if not isinstance(m, cl): + return oslg.mismatch("material", m, cl, mth, CN.DBG, False) + + num = 0 + lcs = m.model().getLayeredConstructions() + + if m.to_MasslessOpaqueMaterial(): + m = m.to_MasslessOpaqueMaterial().get() + + for lc in lcs: + num += lc.getLayerIndices(m).size() + + if num == 1: return True + + if m.to_StandardOpaqueMaterial(): + m = m.to_StandardOpaqueMaterial().get() + + for lc in lcs: + num += lc.getLayerIndices(m).size() + + if num == 1: return True + + return False + + +def assignUniqueMaterial(lc=None, index=None) -> bool: + """Sets a layered construction material as unique. Solution similar to + OpenStudio::Model::LayeredConstruction's 'ensureUniqueLayers', yet limited + here to a single indexed OpenStudio material, typically the principal + insulating material. Returns true if the indexed material is already unique. + Limited to 'standard' or 'massless' materials. + + Args: + lc (OpenStudio::Model::LayeredConstruction): + A construction. + index: + The construction layer index of the material. + + Returns: + True: If assigned as unique. + None: If invalid inputs (see logs). + + """ + mth = "osut.assignUniqueMaterial" + cl = openstudio.model.LayeredConstruction + + if not isinstance(lc, cl): + return oslg.mismatch("construction", lc, cl, mth, CN.DBG, False) + + try: + index = int(index) + except: + return oslg.mismatch("index", index, int, mth, CN.DBG, False) + + if index < 0 or index > lc.numLayers() - 1: + return oslg.invalid("index", mth, 0, CN.DBG, False) + + m = lc.getLayer(index) + + if m.to_MasslessOpaqueMaterial(): + m = m.to_MasslessOpaqueMaterial().get() + + if isUniqueMaterial(m): return True + + mat = m.clone(m.model()).to_MasslessOpaqueMaterial().get() + return lc.setLayer(index, mat) + + if m.to_StandardOpaqueMaterial(): + m = m.to_StandardOpaqueMaterial().get() + + if isUniqueMaterial(m): return True + + mat = m.clone(m.model()).to_StandardOpaqueMaterial().get() + + return False + + +def resetUo(lc=None, film=None, index=None, uo=None, uniq=False) -> float: + """Resets a construction's Uo factor by adjusting its insulating layer + thermal conductivity, then if needed its thickness (or its RSi value if + massless). Unless material uniquness is requested, a matching material is + recovered instead of instantiating a new one. The latter is renamed + according to its adjusted conductivity/thickness (or RSi value). + + Args: + lc (OpenStudio::Model::LayeredConstruction): + A construction. + film (float): + The construction air film resistance. + index (int): + The insulating layer's array index. + uo (float): + Desired Uo factor (with air film resistance). + uniq (bool): + Whether to enforce material uniqueness. + + Returns: + float: New layer RSi [CN.RMIN, CN.RMAX]. + 0.0: If invalid inputs (see logs). + + """ + mth = "osut.resetUo" + r = 0.0 # thermal resistance of new material + cl = openstudio.model.LayeredConstruction + + if not isinstance(lc, cl): + return oslg.mismatch("construction", lc, cl, mth, CN.DBG, r) + if not isinstance(uniq, bool): + uniq = False + + try: + film = float(film) + except: + return oslg.mismatch("film", film, float, mth, CN.DBG, r) + + try: + index = int(index) + except: + return oslg.mismatch("index", index, int, mth, CN.DBG, r) + + try: + uo = float(uo) + except: + return oslg.mismatch("uo", uo, float, mth, CN.DBG, r) + + if film < 0: + return oslg.negative("film", mth, CN.DBG, r) + if index < 0 or index > lc.numLayers() - 1: + return oslg.invalid("index", mth, 3, CN.DBG, r) + if uo < CN.UMIN or uo > CN.UMAX: + uo = clamp(uo, CN.UMIN, CN.UMAX) + msg = "Resetting Uo %s to %.3f (%s)" % (lc.nameString(), uo, mth) + oslg.log(CN.WRN, msg) + + r0 = rsi(lc, film) # current construction RSi value + ro = 1 / uo # desired construction RSi value + dR = ro - r0 # desired increase in construction RSi + m = lc.getLayer(index) + + if m.to_MasslessOpaqueMaterial(): + m = m.to_MasslessOpaqueMaterial().get() + r = m.thermalResistance() + if round(abs(dR), 2) == 0.00: return r + + r = clamp(r + dR, RMIN, RMAX) + id = "OSut:RSi%.2f" % r + mt = lc.model().getMasslessOpaqueMaterialByName(id) + + # Existing material? + if mt: + mt = mt.get() + + if round(r, 2) == round(mt.thermalResistance(), 2) and uniq == False: + lc.setLayer(index, mt) + return r + + mt = m.clone(m.model()).to_MasslessOpaqueMaterial().get() + mt.setName(id) + + if not mt.setThermalResistance(r): + oslg.log(CN.WRN, "Failed to reset %s: RSi%.2f (%s)" % (id, r, mth)) + return 0.0 + + lc.setLayer(index, mt) + return r + + if m.to_StandardOpaqueMaterial(): + m = m.to_StandardOpaqueMaterial().get() + r = m.thickness() / m.conductivity() + if round(abs(dR), 2) == 0.00: return r + + k = clamp(m.thickness() / (r + dR), CN.KMIN, CN.KMAX) + d = clamp(k * (r + dR), CN.DMIN, CN.DMAX) + r = d / k + id = "OSut:K%.3f:%03d" % (k, d*1000) + mt = lc.model().getStandardOpaqueMaterialByName(id) + + # Existing material? + if mt: + mt = mt.get() + rt = mt.thickness() / mt.conductivity() + + if round(r, 2) == round(rt, 2) and uniq == False: + lc.setlayer(index, mt) + return r + + mt = m.clone(m.model()).to_StandardOpaqueMaterial().get() + mt.setName(id) + + if not mt.setThermalConductivity(k): + oslg.log(CN.WRN, "Failed to reset %s: K%.3f (%s)" % (id, k, mth)) + return 0.0 + + if not mt.setThickness(d): + d = int(d*1000) + oslg.log(CN.WRN, "Failed to reset %s: %dmm (%s)" % (id, d, mth)) + return 0.0 + + lc.setLayer(index, mt) + return r + + return 0.0 + + def genConstruction(model=None, specs=dict()): """Generates an OpenStudio multilayered construction, + materials if needed. @@ -345,12 +804,10 @@ def genConstruction(model=None, specs=dict()): except: return oslg.mismatch(id + " Uo", u, float, mth, CN.ERR) - if u < 0: - return oslg.negative(id + " Uo", mth, CN.ERR) - if round(u, 2) == 0: - return oslg.zero(id + " Uo", mth, CN.ERR) - if u > 5.678: - return oslg.invalid(id + " Uo (> 5.678)", mth, 2, CN.ERR) + if u < CN.UMIN or u > CN.UMAX: + u0 = u + u = clamp(u0, CN.UMIN, CN.UMAX) + oslg.log(CN.ERR, "Resetting Uo %.3f to %.3f (%s)" % (u0, u, mth)) # Optional specs. Log/reset if invalid. if "clad" not in specs: specs["clad" ] = "light" # exterior @@ -643,7 +1100,7 @@ def genConstruction(model=None, specs=dict()): if u and not a["glazing"]: ro = 1 / u - flm - if ro > 0: + if ro > CN.RMIN: if specs["type"] == "door": # 1x layer, adjust conductivity layer = c.getLayer(0).to_StandardOpaqueMaterial() @@ -653,38 +1110,14 @@ def genConstruction(model=None, specs=dict()): layer = layer.get() k = layer.thickness() / ro layer.setConductivity(k) - - else: # multiple layers, adjust insulating layer thickness + else: # multiple layers lyr = insulatingLayer(c) - if not lyr["index"] or not lyr["type"] or not lyr["r"]: - return oslg.invalid(id + " construction", mth, 0) + if not lyr["index"] or not lyr["type"] or round(lyr["r"], 2) == 0: + return oslg.invalid(ide + " construction", mth, 0) index = lyr["index"] - layer = c.getLayer(index).to_StandardOpaqueMaterial() - - if not layer: - return oslg.invalid(id + " material %d" % index, mth, 0) - - layer = layer.get() - k = layer.conductivity() - d = (ro - rsi(c) + lyr["r"]) * k - - if d < 0.03: - m = id + " adjusted material thickness" - return oslg.invalid(m, mth, 0) - - nom = re.sub(r'[^a-zA-Z]', '', layer.nameString()) - nom = re.sub(r'OSut', '', nom) - nom = "OSut." + nom + ".%03d" % int(d * 1000) - - if model.getStandardOpaqueMaterialByName(nom): - omat = model.getStandardOpaqueMaterialByName(nom).get() - c.setLayer(index, omat) - else: - layer.setName(nom) - layer.setThickness(d) - + resetUo(c, flm, index, u) return c @@ -874,7 +1307,7 @@ def genMass(sps=None, ratio=2.0) -> bool: return True -def holdsConstruction(cset=None, base=None, gr=False, ex=False, type=""): +def holdsConstruction(cset=None, base=None, gr=False, ex=False, type="") -> bool: """Validates whether a default construction set holds a base construction. Args: @@ -886,7 +1319,7 @@ def holdsConstruction(cset=None, base=None, gr=False, ex=False, type=""): Whether ground-facing surface. ex (bool): Whether exterior-facing surface. - type: + type (str): An OpenStudio surface (or sub surface) type (e.g. "Wall"). Returns: @@ -1012,271 +1445,69 @@ def defaultConstructionSet(s=None): ground = True if s.isGroundSurface() else False exterior = True if bnd == "outdoors" else False + adjacent = None + aspace = None + typ = None + + if s.adjacentSurface(): + adjacent = s.adjacentSurface().get() + typ = adjacent.surfaceType() + + if adjacent.space(): + aspace = adjacent.space().get() if space.defaultConstructionSet(): - cset = space.defaultConstructionSet().get() + set = space.defaultConstructionSet().get() - if holdsConstruction(cset, base, ground, exterior, type): return cset + if holdsConstruction(set, base, ground, exterior, type): return set + elif aspace: + if aspace.defaultConstructionSet(): + set = aspace.defaultConstructionSet().get() + + if holdsConstruction(set, base, ground, exterior, typ): return set if space.spaceType(): spacetype = space.spaceType().get() if spacetype.defaultConstructionSet(): - cset = spacetype.defaultConstructionSet().get() + set = spacetype.defaultConstructionSet().get() + + if holdsConstruction(set, base, ground, exterior, type): return set + elif aspace and aspace.spaceType(): + spacetype = aspace.spaceType().get() + + if spacetype.defaultConstructionSet(): + set = spacetype.defaultConstructionSet().get() - if holdsConstruction(cset, base, ground, exterior, type): - return cset + if holdsConstruction(set, base, ground, exterior, typ): return set if space.buildingStory(): story = space.buildingStory().get() if story.defaultConstructionSet(): - cset = story.defaultConstructionSet().get() + set = story.defaultConstructionSet().get() - if holdsConstruction(cset, base, ground, exterior, type): - return cset + if holdsConstruction(set, base, ground, exterior, type): return set + elif aspace and aspace.buildingStory(): + story = aspace.buildingStory().get() + if story.defaultConstructionSet(): + set = story.defaultConstructionSet().get() + + if holdsConstruction(set, base, ground, exterior, typ): + return set building = mdl.getBuilding() if building.defaultConstructionSet(): - cset = building.defaultConstructionSet().get() + set = building.defaultConstructionSet().get() - if holdsConstruction(cset, base, ground, exterior, type): - return cset + if holdsConstruction(set, base, ground, exterior, type): + return set return None -def areStandardOpaqueLayers(lc=None) -> bool: - """Validates if every material in a layered construction is standard/opaque. - - Args: - lc (openstudio.model.LayeredConstruction): - an OpenStudio layered construction - - Returns: - True: If all layers are valid (standard & opaque). - False: If invalid inputs (see logs). - - """ - mth = "osut.areStandardOpaqueLayers" - cl = openstudio.model.LayeredConstruction - - if not isinstance(lc, cl): - return oslg.mismatch("lc", lc, cl, mth, CN.DBG, 0.0) - - for m in lc.layers(): - if not m.to_StandardOpaqueMaterial(): return False - - return True - - -def thickness(lc=None) -> float: - """Returns total (standard opaque) layered construction thickness (m). - - Args: - lc (openstudio.model.LayeredConstruction): - an OpenStudio layered construction - - Returns: - float: A standard opaque construction thickness. - 0.0: If invalid inputs (see logs). - - """ - mth = "osut.thickness" - cl = openstudio.model.LayeredConstruction - d = 0.0 - - if not isinstance(lc, cl): - return oslg.mismatch("lc", lc, cl, mth, CN.DBG, 0.0) - if not areStandardOpaqueLayers(lc): - oslg.log(CN.ERR, "holding non-StandardOpaqueMaterial(s) %s" % mth) - return d - - for m in lc.layers(): d += m.thickness() - - return d - - -def glazingAirFilmRSi(usi=5.85) -> float: - """Returns total air film resistance of a fenestrated construction (m2•K/W). - - Args: - usi (float): - A fenestrated construction's U-factor (W/m2•K). - - Returns: - float: Total air film resistances. - 0.1216: If invalid input (see logs). - - """ - # The sum of thermal resistances of calculated exterior and interior film - # coefficients under standard winter conditions are taken from: - # - # https://bigladdersoftware.com/epx/docs/9-6/engineering-reference/ - # window-calculation-module.html#simple-window-model - # - # These remain acceptable approximations for flat windows, yet likely - # unsuitable for subsurfaces with curved or projecting shapes like domed - # skylights. The solution here is considered an adequate fix for reporting, - # awaiting eventual OpenStudio (and EnergyPlus) upgrades to report NFRC 100 - # (or ISO) air film resistances under standard winter conditions. - # - # For U-factors above 8.0 W/m2•K (or invalid input), the function returns - # 0.1216 m2•K/W, which corresponds to a construction with a single glass - # layer thickness of 2mm & k = ~0.6 W/m.K. - # - # The EnergyPlus Engineering calculations were designed for vertical - # windows, not for horizontal, slanted or domed surfaces - use with caution. - mth = "osut.glazingAirFilmRSi" - val = 0.1216 - - try: - usi = float(usi) - except: - return oslg.mismatch("usi", usi, float, mth, CN.DBG, val) - - if usi > 8.0: - return oslg.invalid("usi", mth, 1, CN.WRN, val) - elif usi < 0: - return oslg.negative("usi", mth, CN.WRN, val) - elif abs(usi) < CN.TOL: - return oslg.zero("usi", mth, CN.WRN, val) - - rsi = 1 / (0.025342 * usi + 29.163853) # exterior film, next interior film - - if usi < 5.85: - return rsi + 1 / (0.359073 * math.log(usi) + 6.949915) - - return rsi + 1 / (1.788041 * usi - 2.886625) - - -def rsi(lc=None, film=0.0, t=0.0) -> float: - """Returns a construction's 'standard calc' thermal resistance (m2•K/W), - which includes air film resistances. It excludes insulating effects of - shades, screens, etc. in the case of fenestrated constructions. Adapted - from BTAP's 'Material' Module "get_conductance" (P. Lopez). - - Args: - lc (openstudio.model.LayeredConstruction): - an OpenStudio layered construction - film (float): - thermal resistance of surface air films (m2•K/W) - t (float): - gas temperature (°C) (optional) - - Returns: - float: A layered construction's thermal resistance. - 0.0: If invalid input (see logs). - - """ - mth = "osut.rsi" - cl = openstudio.model.LayeredConstruction - - if not isinstance(lc, cl): - return oslg.mismatch("lc", lc, cl, mth, CN.DBG, 0.0) - - try: - film = float(film) - except: - return oslg.mismatch("film", film, float, mth, CN.DBG, 0.0) - - try: - t = float(t) - except: - return oslg.mismatch("temp K", t, float, mth, CN.DBG, 0.0) - - t += 273.0 # °C to K - - if t < 0: - return oslg.negative("temp K", mth, CN.ERR, 0.0) - if film < 0: - return oslg.negative("film", mth, CN.ERR, 0.0) - - rsi = film - - for m in lc.layers(): - if m.to_SimpleGlazing(): - return 1 / m.to_SimpleGlazing().get().uFactor() - elif m.to_StandardGlazing(): - rsi += m.to_StandardGlazing().get().thermalResistance() - elif m.to_RefractionExtinctionGlazing(): - rsi += m.to_RefractionExtinctionGlazing().get().thermalResistance() - elif m.to_Gas(): - rsi += m.to_Gas().get().getThermalResistance(t) - elif m.to_GasMixture(): - rsi += m.to_GasMixture().get().getThermalResistance(t) - - # Opaque materials next. - if m.to_StandardOpaqueMaterial(): - rsi += m.to_StandardOpaqueMaterial().get().thermalResistance() - elif m.to_MasslessOpaqueMaterial(): - rsi += m.to_MasslessOpaqueMaterial() - elif m.to_RoofVegetation(): - rsi += m.to_RoofVegetation().get().thermalResistance() - elif m.to_AirGap(): - rsi += m.to_AirGap().get().thermalResistance() - - return rsi - - -def insulatingLayer(lc=None) -> dict: - """Identifies a layered construction's (opaque) insulating layer. - - Args: - lc (openStudio.model.LayeredConstruction): - an OpenStudio layered construction - - Returns: - An insulating-layer dictionary: - - "index" (int): construction's insulating layer index [0, n layers) - - "type" (str): layer material type ("standard" or "massless") - - "r" (float): material thermal resistance in m2•K/W. - If unsuccessful, dictionary is voided as follows (see logs): - "index": None - "type": None - "r": 0.0 - - """ - mth = "osut.insulatingLayer" - cl = openstudio.model.LayeredConstruction - res = dict(index=None, type=None, r=0.0) - i = 0 # iterator - - if not isinstance(lc, cl): - return oslg.mismatch("lc", lc, cl, mth, CN.DBG, res) - - for l in lc.layers(): - if l.to_MasslessOpaqueMaterial(): - l = l.to_MasslessOpaqueMaterial().get() - - if l.thermalResistance() < 0.001 or l.thermalResistance() < res["r"]: - i += 1 - continue - else: - res["r" ] = m.thermalResistance() - res["index"] = i - res["type" ] = "massless" - - if l.to_StandardOpaqueMaterial(): - l = l.to_StandardOpaqueMaterial().get() - k = l.thermalConductivity() - d = l.thickness() - - if (d < 0.003) or (k > 3.0) or (d / k < res["r"]): - i += 1 - continue - else: - res["r" ] = d / k - res["index"] = i - res["type" ] = "standard" - - i += 1 - - return res - - def areSpandrels(surfaces=None) -> bool: """Validates whether one or more opaque surface(s) can be considered as curtain wall (or similar technology) spandrels, regardless of construction @@ -1289,6 +1520,7 @@ def areSpandrels(surfaces=None) -> bool: Returns: bool: Whether surface(s) can be considered 'spandrels'. False: If invalid input (see logs). + """ mth = "osut.areSpandrels" cl = openstudio.model.Surface @@ -1384,7 +1616,7 @@ def isFenestrated(s=None) -> bool: # - UNCONDITIONED space: an ENCLOSED space that is NOT a conditioned # space or a SEMIHEATED space (see above). # -# NOTE: Crawlspaces, attics, and parking garages with natural or +# Note: Crawlspaces, attics, and parking garages with natural or # mechanical ventilation are considered UNENCLOSED spaces. # # 2.3.3 Modeling Requirements: surfaces adjacent to UNENCLOSED spaces @@ -5380,7 +5612,7 @@ def genAnchors(s=None, sset=[], tag="box") -> int: # Validate individual subsets. Purge surface-specific leader line anchors. for i, st in enumerate(sset): str1 = ide + "subset %d" % (i+1) - str2 = str1 + " %s" % str(tag) + str2 = str1 + " %s" % oslg.trim(tag) if not isinstance(st, dict): return oslg.mismatch(str1, st, dict, mth, CN.DBG, n) @@ -5559,7 +5791,7 @@ def genExtendedVertices(s=None, sset=[], tag="vtx") -> openstudio.Point3dVector: # Validate individual subsets. for i, st in enumerate(sset): str1 = ide + "subset %d" % (i+1) - str2 = str1 + " %s" % str(tag) + str2 = str1 + " %s" % oslg.trim(tag) if not isinstance(st, dict): return oslg.mismatch(str1, st, dict, mth, CN.DBG, a) diff --git a/tests/test_osut.py b/tests/test_osut.py index 395ed54..b618894 100644 --- a/tests/test_osut.py +++ b/tests/test_osut.py @@ -1,6 +1,6 @@ # BSD 3-Clause License # -# Copyright (c) 2022-2025, rd2 +# Copyright (c) 2022-2026, rd2 # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: @@ -130,7 +130,7 @@ def test05_construction_generation(self): self.assertEqual(len(c.layers()), 4) self.assertEqual(c.layers()[0].nameString(), "OSut.material.015") self.assertEqual(c.layers()[1].nameString(), "OSut.drywall.015") - self.assertEqual(c.layers()[2].nameString(), "OSut.mineral.106") + self.assertEqual(c.layers()[2].nameString(), "OSut:K0.047:100") self.assertEqual(c.layers()[3].nameString(), "OSut.drywall.015") r = osut.rsi(c, osut.film()["wall"]) u = osut.uo()["wall"] @@ -192,7 +192,7 @@ def test05_construction_generation(self): self.assertTrue(c.layers()) self.assertEqual(len(c.layers()), 3) self.assertEqual(c.layers()[0].nameString(), "OSut.drywall.015") - self.assertEqual(c.layers()[1].nameString(), "OSut.mineral.216") + self.assertEqual(c.layers()[1].nameString(), "OSut:K0.023:100") self.assertEqual(c.layers()[2].nameString(), "OSut.drywall.015") self.assertTrue("uo" in specs) self.assertAlmostEqual(specs["uo"], 0.214, places=2) @@ -214,7 +214,7 @@ def test05_construction_generation(self): self.assertTrue(c.layers()) self.assertEqual(len(c.layers()), 3) self.assertEqual(c.layers()[0].nameString(), "OSut.drywall.015") - self.assertEqual(c.layers()[1].nameString(), "OSut.mineral.216") + self.assertEqual(c.layers()[1].nameString(), "OSut:K0.023:100") self.assertEqual(c.layers()[2].nameString(), "OSut.drywall.015") self.assertTrue("uo" in specs) self.assertAlmostEqual(specs["uo"], 0.214, places=3) @@ -237,7 +237,7 @@ def test05_construction_generation(self): self.assertEqual(len(c.layers()), 4) self.assertEqual(c.layers()[0].nameString(), "OSut.material.015") self.assertEqual(c.layers()[1].nameString(), "OSut.drywall.015") - self.assertEqual(c.layers()[2].nameString(), "OSut.mineral.210") + self.assertEqual(c.layers()[2].nameString(), "OSut:K0.024:100") self.assertEqual(c.layers()[3].nameString(), "OSut.drywall.015") self.assertTrue("uo" in specs) self.assertAlmostEqual(specs["uo"], 0.214, places=3) @@ -259,7 +259,7 @@ def test05_construction_generation(self): self.assertTrue(c.layers()) self.assertEqual(len(c.layers()), 2) self.assertEqual(c.layers()[0].nameString(), "OSut.drywall.015") - self.assertEqual(c.layers()[1].nameString(), "OSut.mineral.221") + self.assertEqual(c.layers()[1].nameString(), "OSut:K0.023:100") self.assertTrue("uo" in specs) self.assertAlmostEqual(specs["uo"], 0.214, places=3) r = osut.rsi(c, osut.film()["wall"]) @@ -360,7 +360,7 @@ def test05_construction_generation(self): self.assertTrue(c.layers()) self.assertEqual(len(c.layers()), 3) self.assertEqual(c.layers()[0].nameString(), "OSut.material.015") - self.assertEqual(c.layers()[1].nameString(), "OSut.mineral.215") + self.assertEqual(c.layers()[1].nameString(), "OSut:K0.023:100") self.assertEqual(c.layers()[2].nameString(), "OSut.drywall.015") self.assertTrue("uo" in specs) self.assertAlmostEqual(specs["uo"], 0.214, places=2) @@ -382,7 +382,7 @@ def test05_construction_generation(self): self.assertTrue(c.layers()) self.assertEqual(len(c.layers()), 3) self.assertEqual(c.layers()[0].nameString(), "OSut.material.015") - self.assertEqual(c.layers()[1].nameString(), "OSut.polyiso.108") + self.assertEqual(c.layers()[1].nameString(), "OSut:K0.023:100") self.assertEqual(c.layers()[2].nameString(), "OSut.concrete.100") self.assertTrue("uo" in specs) self.assertAlmostEqual(specs["uo"], 0.214, places=3) @@ -404,7 +404,7 @@ def test05_construction_generation(self): self.assertTrue(c.layers()) self.assertEqual(len(c.layers()), 2) self.assertEqual(c.layers()[0].nameString(), "OSut.concrete.200") - self.assertEqual(c.layers()[1].nameString(), "OSut.polyiso.110") + self.assertEqual(c.layers()[1].nameString(), "OSut:K0.023:100") self.assertTrue("uo" in specs) self.assertAlmostEqual(specs["uo"], 0.214, places=3) r = osut.rsi(c, osut.film()["roof"]) @@ -441,7 +441,7 @@ def test05_construction_generation(self): self.assertTrue(c.layers()) self.assertEqual(len(c.layers()), 2) self.assertEqual(c.layers()[0].nameString(), "OSut.material.015") - self.assertEqual(c.layers()[1].nameString(), "OSut.cellulose.217") + self.assertEqual(c.layers()[1].nameString(), "OSut:K0.023:100") self.assertTrue("uo" in specs) self.assertAlmostEqual(specs["uo"], 0.214, places=3) r = osut.rsi(c, osut.film()["floor"]) @@ -462,7 +462,7 @@ def test05_construction_generation(self): self.assertTrue(c.layers()) self.assertEqual(len(c.layers()), 3) self.assertEqual(c.layers()[0].nameString(), "OSut.material.015") - self.assertEqual(c.layers()[1].nameString(), "OSut.mineral.211") + self.assertEqual(c.layers()[1].nameString(), "OSut:K0.024:100") self.assertEqual(c.layers()[2].nameString(), "OSut.material.015") self.assertTrue("uo" in specs) self.assertAlmostEqual(specs["uo"], 0.214, places=3) @@ -484,7 +484,7 @@ def test05_construction_generation(self): self.assertTrue(c.layers()) self.assertEqual(len(c.layers()), 3) self.assertEqual(c.layers()[0].nameString(), "OSut.material.015") - self.assertEqual(c.layers()[1].nameString(), "OSut.mineral.214") + self.assertEqual(c.layers()[1].nameString(), "OSut:K0.023:100") self.assertEqual(c.layers()[2].nameString(), "OSut.concrete.100") self.assertTrue("uo" in specs) self.assertAlmostEqual(specs["uo"], 0.214, places=3) @@ -523,7 +523,7 @@ def test05_construction_generation(self): self.assertTrue(c.layers()) self.assertEqual(len(c.layers()), 3) self.assertEqual(c.layers()[0].nameString(), "OSut.sand.100") - self.assertEqual(c.layers()[1].nameString(), "OSut.polyiso.109") + self.assertEqual(c.layers()[1].nameString(), "OSut:K0.010:043") self.assertEqual(c.layers()[2].nameString(), "OSut.concrete.100") self.assertTrue("uo" in specs) self.assertAlmostEqual(specs["uo"], 0.214, places=3) @@ -561,7 +561,7 @@ def test05_construction_generation(self): self.assertTrue(c.layers()) self.assertEqual(len(c.layers()), 3) self.assertEqual(c.layers()[0].nameString(), "OSut.concrete.200") - self.assertEqual(c.layers()[1].nameString(), "OSut.mineral.100") + self.assertEqual(c.layers()[1].nameString(), "OSut:K0.037:075") self.assertEqual(c.layers()[2].nameString(), "OSut.drywall.015") self.assertTrue("uo" in specs) self.assertAlmostEqual(specs["uo"], 0.428, places=3) @@ -763,6 +763,7 @@ def test06_internal_mass(self): def test07_construction_thickness(self): o = osut.oslg + print(o.logs()) self.assertEqual(o.status(), 0) self.assertEqual(o.level(), DBG) @@ -5976,4 +5977,4 @@ def test38_space_height_width(self): self.assertEqual(o.status(), 0) if __name__ == "__main__": - unittest.main() + unittest.main(failfast=True) From 76c3b58aed6a5a2fdb849d1ccbb60a1afc3b6b68 Mon Sep 17 00:00:00 2001 From: brgix Date: Sat, 3 Jan 2026 04:31:40 -0500 Subject: [PATCH 2/4] Clarification on constants --- .github/workflows/pull_request.yml | 2 +- src/osut/osut.py | 14 +++-- tests/test_osut.py | 96 ++++++++++++++++++++++-------- 3 files changed, 80 insertions(+), 32 deletions(-) diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index ebeeaeb..deb6b88 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.10", "3.x"] + python-version: ["3.9"] steps: - name: Check out repository diff --git a/src/osut/osut.py b/src/osut/osut.py index 9c1cedb..8102d95 100644 --- a/src/osut/osut.py +++ b/src/osut/osut.py @@ -46,10 +46,10 @@ class _CN: HEAD = 2.032 # standard 80" door SILL = 0.762 # standard 30" window sill NS = "nameString" # OpenStudio object identifier method - DMIN = 0.010 # min. material thickness - DMAX = 1.000 # max. material thickness - KMIN = 0.010 # min. material thermal conductivity - KMAX = 2.000 # max. material thermal conductivity + DMIN = 0.010 # min. insulating material thickness + DMAX = 1.000 # max. insulating material thickness + KMIN = 0.010 # min. insulating material thermal conductivity + KMAX = 2.000 # max. insulating material thermal conductivity UMAX = KMAX / DMIN # material USi upper limit, 200.000 UMIN = KMIN / DMAX # material USi lower limit, 0.010 RMIN = 1.0 / UMAX # material RSi lower limit, 0.005 (or R-IP 0.03) @@ -1473,7 +1473,8 @@ def defaultConstructionSet(s=None): set = spacetype.defaultConstructionSet().get() if holdsConstruction(set, base, ground, exterior, type): return set - elif aspace and aspace.spaceType(): + + if aspace and aspace.spaceType(): spacetype = aspace.spaceType().get() if spacetype.defaultConstructionSet(): @@ -1488,7 +1489,8 @@ def defaultConstructionSet(s=None): set = story.defaultConstructionSet().get() if holdsConstruction(set, base, ground, exterior, type): return set - elif aspace and aspace.buildingStory(): + + if aspace and aspace.buildingStory(): story = aspace.buildingStory().get() if story.defaultConstructionSet(): diff --git a/tests/test_osut.py b/tests/test_osut.py index b618894..43cec53 100644 --- a/tests/test_osut.py +++ b/tests/test_osut.py @@ -5102,6 +5102,7 @@ def test34_generated_skylight_wells(self): self.assertEqual(o.reset(DBG), DBG) self.assertEqual(o.level(), DBG) + srr = 0.05 version = int("".join(openstudio.openStudioVersion().split("."))) translator = openstudio.osversion.VersionTranslator() @@ -5110,34 +5111,79 @@ def test34_generated_skylight_wells(self): self.assertTrue(model) model = model.get() - srr = 0.05 + s = model.getSurfaceByName("Perimeter_ZN_1_ceiling") + self.assertTrue(s) + s = s.get() + self.assertTrue(s.isConstructionDefaulted()) # yet which set? + + type = s.surfaceType() + self.assertEqual(type.lower(), "roofceiling") + base = s.construction() + self.assertTrue(base) + base = base.get() + + # Check OpenStudio space-to-building hierarchy. + space = s.space() + self.assertTrue(space) + space = space.get() + self.assertFalse(space.defaultConstructionSet()) + + spacetype = space.spaceType() + self.assertTrue(spacetype) + spacetype = spacetype.get() + self.assertFalse(spacetype.defaultConstructionSet()) + + story = space.buildingStory() + self.assertTrue(story) + story = story.get() + self.assertFalse(story.defaultConstructionSet()) + + building = model.getBuilding() + self.assertTrue(building.defaultConstructionSet()) + bset = building.defaultConstructionSet().get() + oID = bset.nameString() + self.assertEqual(oID, "90.1-2010 - SmOffice - ASHRAE 169-2013-3B") + self.assertFalse(osut.holdsConstruction(bset, base, False, False, type)) + + # Check for adjacent surface. + adjacent = s.adjacentSurface() + self.assertTrue(adjacent) + adjacent = adjacent.get() + atype = adjacent.surfaceType() + self.assertEqual(atype.lower(), "floor") + + attic = adjacent.space() + self.assertTrue(attic) + attic = attic.get() + self.assertFalse(attic.defaultConstructionSet()) + + spacetype = attic.spaceType() + self.assertTrue(spacetype) + spacetype = spacetype.get() + aset = spacetype.defaultConstructionSet() + self.assertTrue(aset) + aset = aset.get() + aID = aset.nameString() + self.assertEqual(aID, "90.1-2010 - - Attic - ASHRAE 169-2013-3B") + self.assertTrue(osut.holdsConstruction(aset, base, False, False, atype)) + + set = osut.defaultConstructionSet(s) + self.assertEqual(set, aset) + + self.assertTrue(bset.defaultInteriorSurfaceConstructions()) + self.assertTrue(aset.defaultInteriorSurfaceConstructions()) + ib_set = bset.defaultInteriorSurfaceConstructions().get() + ia_set = aset.defaultInteriorSurfaceConstructions().get() + self.assertTrue(ib_set.wallConstruction()) + self.assertFalse(ia_set.wallConstruction()) + ib_wall = ib_set.wallConstruction().get().to_LayeredConstruction() + self.assertTrue(ib_wall) + ib_wall = ib_wall.get() + self.assertAlmostEqual(osut.rsi(ib_wall, 0.150), 0.31, places=2) + core = [] attic = [] - # Fetch default construction sets. - oID = "90.1-2010 - SmOffice - ASHRAE 169-2013-3B" # building - aID = "90.1-2010 - - Attic - ASHRAE 169-2013-3B" # attic spacetype level - o_set = model.getDefaultConstructionSetByName(oID) - a_set = model.getDefaultConstructionSetByName(oID) - self.assertTrue(o_set) - self.assertTrue(a_set) - o_set = o_set.get() - a_set = a_set.get() - self.assertTrue(o_set.defaultInteriorSurfaceConstructions()) - self.assertTrue(a_set.defaultInteriorSurfaceConstructions()) - io_set = o_set.defaultInteriorSurfaceConstructions().get() - ia_set = a_set.defaultInteriorSurfaceConstructions().get() - self.assertTrue(io_set.wallConstruction()) - self.assertTrue(ia_set.wallConstruction()) - io_wall = io_set.wallConstruction().get().to_LayeredConstruction() - ia_wall = ia_set.wallConstruction().get().to_LayeredConstruction() - self.assertTrue(io_wall) - self.assertTrue(ia_wall) - io_wall = io_wall.get() - ia_wall = ia_wall.get() - self.assertEqual(io_wall, ia_wall) # 2x drywall layers - self.assertAlmostEqual(osut.rsi(io_wall, 0.150), 0.31, places=2) - for space in model.getSpaces(): ide = space.nameString() From 2865573ea80cab8eaa795c8e27452fa4aeb62864 Mon Sep 17 00:00:00 2001 From: brgix Date: Sat, 3 Jan 2026 04:44:23 -0500 Subject: [PATCH 3/4] Adds OS 3.10 GitHub Action runs --- .github/workflows/pull_request.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index deb6b88..b132536 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.9"] + python-version: ["3.9", "3.10"] steps: - name: Check out repository From fb776132cbe4f39b5ce678d346ada15e5fdfe735 Mon Sep 17 00:00:00 2001 From: brgix Date: Sat, 3 Jan 2026 12:36:56 -0500 Subject: [PATCH 4/4] Revises version number, in line with OSut --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 6de4827..5655d90 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "osut" -version = "0.8.0" +version = "0.8.1" description = "OpenStudio SDK utilities for Python" readme = "README.md" requires-python = ">=3.2"