Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 76 additions & 50 deletions src/osut/osut.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,8 @@ class _CN:

# Default (~1980s) envelope Uo (W/m2•K), based on surface type.
_uo = dict(
shading = None, # N/A
partition = None, # N/A
shading = None, # N/A
partition = None, # N/A
wall = 0.384, # rated R14.8 hr•ft2F/Btu
roof = 0.327, # rated R17.6 hr•ft2F/Btu
floor = 0.317, # rated R17.9 hr•ft2F/Btu (exposed floor)
Expand Down Expand Up @@ -335,9 +335,8 @@ def genConstruction(model=None, specs=dict()):
ide = "OSut.CON." + specs["type"]
if specs["type"] not in uo():
return oslg.invalid("surface type", mth, 2, CN.ERR)
if "uo" not in specs:
specs["uo"] = uo()[ specs["type"] ]

if "uo" not in specs: specs["uo"] = uo()[ specs["type"] ] # can be None
u = specs["uo"]

if u:
Expand All @@ -348,6 +347,8 @@ def genConstruction(model=None, specs=dict()):

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)

Expand Down Expand Up @@ -581,15 +582,15 @@ def genConstruction(model=None, specs=dict()):
a["compo" ]["id" ] = "OSut." + mt + ".%03d" % int(d * 1000)

elif specs["type"] == "window":
a["glazing"]["u" ] = specs["uo"]
a["glazing"]["u" ] = u if u else uo()["window"]
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A user could set a glazing assembly's specs["uo"] to None. This is caught and reset here.

a["glazing"]["shgc"] = 0.450
if "shgc" in specs: a["glazing"]["shgc"] = specs["shgc"]
a["glazing"]["id" ] = "OSut.window"
a["glazing"]["id" ] += ".U%.1f" % a["glazing"]["u"]
a["glazing"]["id" ] += ".SHGC%d" % (a["glazing"]["shgc"]*100)

elif specs["type"] == "skylight":
a["glazing"]["u" ] = specs["uo"]
a["glazing"]["u" ] = u if u else uo()["skylight"]
a["glazing"]["shgc"] = 0.450
if "shgc" in specs: a["glazing"]["shgc"] = specs["shgc"]
a["glazing"]["id" ] = "OSut.skylight"
Expand All @@ -599,14 +600,14 @@ def genConstruction(model=None, specs=dict()):
if a["glazing"]:
layers = openstudio.model.FenestrationMaterialVector()

u = a["glazing"]["u" ]
u0 = a["glazing"]["u" ]
shgc = a["glazing"]["shgc"]
lyr = model.getSimpleGlazingByName(a["glazing"]["id"])

if lyr:
lyr = lyr.get()
else:
lyr = openstudio.model.SimpleGlazing(model, u, shgc)
lyr = openstudio.model.SimpleGlazing(model, u0, shgc)
lyr.setName(a["glazing"]["id"])

layers.append(lyr)
Expand Down Expand Up @@ -635,49 +636,54 @@ def genConstruction(model=None, specs=dict()):

layers.append(lyr)

c = openstudio.model.Construction(layers)
c = openstudio.model.Construction(layers)
c.setName(ide)

# Adjust insulating layer thickness or conductivity to match requested Uo.
if not a["glazing"]:
ro = 1 / specs["uo"] - film()[specs["type"]] if specs["uo"] else 0
if u and not a["glazing"]:
ro = 1 / u - flm
Copy link
Copy Markdown
Member Author

@brgix brgix Aug 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For opaque construction, adjusting insulating layer thickness (to meet a requested assembly Uo factor) is skipped entirely if the requested Uo isn't:

  • successfully converted to a float
  • within postulated (acceptable) range


if specs["type"] == "door": # 1x layer, adjust conductivity
layer = c.getLayer(0).to_StandardOpaqueMaterial()
if ro > 0:
if specs["type"] == "door": # 1x layer, adjust conductivity
layer = c.getLayer(0).to_StandardOpaqueMaterial()

if not layer:
return oslg.invalid(id + " standard material?", mth, 0)
if not layer:
return oslg.invalid(id + " standard material?", mth, 0)

layer = layer.get()
k = layer.thickness() / ro
layer.setConductivity(k)
layer = layer.get()
k = layer.thickness() / ro
layer.setConductivity(k)

elif ro > 0: # multiple layers, adjust insulating layer thickness
lyr = insulatingLayer(c)
else: # multiple layers, adjust insulating layer thickness
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 not lyr["r"]:
return oslg.invalid(id + " construction", mth, 0)

index = lyr["index"]
layer = c.getLayer(index).to_StandardOpaqueMaterial()
index = lyr["index"]
layer = c.getLayer(index).to_StandardOpaqueMaterial()

if not layer:
return oslg.invalid(id + " material %d" % index, mth, 0)
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
layer = layer.get()
k = layer.conductivity()
d = (ro - rsi(c) + lyr["r"]) * k

if d < 0.03:
return oslg.invalid(id + " adjusted material thickness", mth, 0)
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)
nom = re.sub(r'[^a-zA-Z]', '', layer.nameString())
nom = re.sub(r'OSut', '', nom)
nom = "OSut." + nom + ".%03d" % int(d * 1000)

if not model.getStandardOpaqueMaterialByName(nom):
layer.setName(nom)
layer.setThickness(d)
if model.getStandardOpaqueMaterialByName(nom):
omat = model.getStandardOpaqueMaterialByName(nom).get()
c.setLayer(index, omat)
else:
layer.setName(nom)
layer.setThickness(d)

return c

Expand Down Expand Up @@ -1650,18 +1656,27 @@ def scheduleIntervalMinMax(sched=None) -> dict:
- "min" (float): min temperature. (None if invalid inputs - see logs).
- "max" (float): max temperature. (None if invalid inputs - see logs).
"""
mth = "osut.scheduleCompactMinMax"
mth = "osut.scheduleIntervalMinMax"
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

typo

cl = openstudio.model.ScheduleInterval
vals = []
res = dict(min=None, max=None)

if not isinstance(sched, cl):
return oslg.mismatch("sched", sched, cl, mth, CN.DBG, res)

vals = sched.timeSeries().values()
values = sched.timeSeries().values()

res["min"] = min(values)
res["max"] = max(values)
for i in range(len(values)):
try:
value = float(values[i])
vals.append(value)
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Better.

except:
oslg.invalid("numerical at %d" % i, mth, 1, CN.ERR)

if not vals: return res

res["min"] = min(vals)
res["max"] = max(vals)

try:
res["min"] = float(res["min"])
Expand Down Expand Up @@ -2595,6 +2610,17 @@ def availabilitySchedule(model=None, avl=""):

return schedule

# ---- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- ---- #
# This final set of utilities targets OpenStudio geometry. Many of the
# following geometry methods rely on Boost as an OpenStudio dependency.
# As per Boost requirements, points (e.g. vertical polygon) must be 'aligned':
# - first rotated/tilted as to lay flat along XY plane (Z-axis ~= 0)
# - initial Z-axis values now become Y-axis values
# - points with the lowest X-axis values are 'aligned' along X-axis (0)
# - points with the lowest Z-axis values are 'aligned' along Y-axis (0)
# - for several Boost methods, points must be clockwise in sequence
#
# Check OSut's poly() method, which offers such Boost-related options.

def transforms(group=None) -> dict:
""""Returns OpenStudio site/space transformation & rotation angle.
Expand Down Expand Up @@ -2698,7 +2724,7 @@ def p3Dv(pts=None) -> openstudio.Point3dVector:
pts (list): OpenStudio 3D points.

Returns:
openstudio.Point3dVector: Vector of 3D points (see logs if empty).
openstudio.Point3dVector: Vector of 3D points (see 'p3Dv' logs if empty).

"""
mth = "osut.p3Dv"
Expand Down Expand Up @@ -3444,13 +3470,13 @@ def lineIntersection(s1=[], s2=[]):
xa1b1 = a.cross(a1b1)
xa1b2 = a.cross(a1b2)

if xa1b1.length() < CN.TOL2:
if isPointAlongSegment(a1, [a2, b1]): return None
if isPointAlongSegment(a2, [a1, b1]): return None

if xa1b2.length() < CN.TOL2:
if isPointAlongSegment(a1, [a2, b2]): return None
if isPointAlongSegment(a2, [a1, b2]): return None
# if xa1b1.length() < CN.TOL2:
# if isPointAlongSegment(a1, [a2, b1]): return None
# if isPointAlongSegment(a2, [a1, b1]): return None
#
# if xa1b2.length() < CN.TOL2:
# if isPointAlongSegment(a1, [a2, b2]): return None
# if isPointAlongSegment(a2, [a1, b2]): return None

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now redundant checks.

# Both segment endpoints can't be 'behind' point.
if a.dot(a1b1) < 0 and a.dot(a1b2) < 0: return None
Expand Down Expand Up @@ -6079,7 +6105,7 @@ def genSlab(pltz=[], z=0) -> openstudio.Point3dVector:
slb = vtx

# Once joined, re-adjust Z-axis coordinates.
if abs(z) > CN.TOL:
if round(z, 2) != 0.00:
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

More appropriate.

vtx = openstudio.Point3dVector()

for pt in slb: vtx.append(openstudio.Point3d(pt.x(), pt.y(), z))
Expand Down
88 changes: 76 additions & 12 deletions tests/test_osut.py
Original file line number Diff line number Diff line change
Expand Up @@ -392,7 +392,7 @@ def test05_construction_generation(self):
self.assertEqual(o.status(), 0)
del model

# Insulated (conditioned), parking garage roof (polyiso under 8" slab).
# Roof above conditioned parking garage (polyiso under 8" slab).
specs = dict(type="roof", uo=0.214, clad="heavy", frame="medium", finish="none")
model = openstudio.model.Model()
c = osut.genConstruction(model, specs)
Expand Down Expand Up @@ -649,6 +649,41 @@ def test05_construction_generation(self):
self.assertEqual(o.status(), 0)
del model

# Invalid Uo (here, skylights and windows inherit default Uo values)
specs = dict(type="skylight", uo=None)
model = openstudio.model.Model()
c = osut.genConstruction(model, specs)
self.assertEqual(o.status(), 0)
self.assertFalse(o.logs())
self.assertTrue(c)
self.assertTrue(isinstance(c, openstudio.model.Construction))
self.assertEqual(c.nameString(), "OSut.CON.skylight")
self.assertTrue(c.layers())
self.assertEqual(len(c.layers()), 1)
self.assertEqual(c.layers()[0].nameString(), "OSut.skylight.U3.5.SHGC45")
r = osut.rsi(c)
self.assertAlmostEqual(r, 1/osut.uo()["skylight"], places=3)
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the case of fenestrated assemblies, an invalid Uo request is ignored and the glazing assembly inherits a default OSut Uo (here 3.5 W/m2.K for a skylight).

self.assertFalse(o.logs())
self.assertEqual(o.status(), 0)
del model

# Invalid Uo (here, Uo-adjustments are ignored altogether)
specs = dict(type="wall", uo=None)
model = openstudio.model.Model()
c = osut.genConstruction(model, specs)
self.assertEqual(o.status(), 0)
self.assertFalse(o.logs())
self.assertTrue(c)
self.assertTrue(isinstance(c, openstudio.model.Construction))
self.assertEqual(c.nameString(), "OSut.CON.wall")
self.assertTrue(c.layers())
self.assertEqual(len(c.layers()), 4)
r = osut.rsi(c)
self.assertAlmostEqual(1/r, 2.23, places=2) # not matching any defaults
self.assertFalse(o.logs())
self.assertEqual(o.status(), 0)
del model

def test06_internal_mass(self):
o = osut.oslg
self.assertEqual(o.status(), 0)
Expand Down Expand Up @@ -1696,9 +1731,27 @@ def test17_minmax_heatcool_setpoints(self):
self.assertTrue(cc.setTemperatureCalculationRequestedAfterLayerNumber(1))
self.assertTrue(floor.setConstruction(cc))

# Test 'fixed interval' schedule. Annual time series - no variation.
start = model.getYearDescription().makeDate(1, 1)
inter = openstudio.Time(0, 1, 0, 0)
values = openstudio.createVector([22.78] * 8760)
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OSut enabled ScheduleFixedInterval checks, e.g.:

  • min/max setpoint temperatures
  • is space UNCONDITIONED, etc.

Yet no unit test had been in place.

series = openstudio.TimeSeries(start, inter, values, "")
limits = openstudio.model.ScheduleTypeLimits(model)
limits.setName("Radiant Electric Heating Setpoint Schedule Type Limits")
self.assertTrue(limits.setNumericType("Continuous"))
self.assertTrue(limits.setUnitType("Temperature"))

schedule = openstudio.model.ScheduleFixedInterval(model)
schedule.setName("Radiant Electric Heating Setpoint Schedule")
self.assertTrue(schedule.setTimeSeries(series))
self.assertTrue(schedule.setTranslatetoScheduleFile(False))
self.assertTrue(schedule.setScheduleTypeLimits(limits))

tvals = schedule.timeSeries().values()
self.assertTrue(isinstance(tvals, openstudio.Vector))
for i in range(len(tvals)): self.assertTrue(isinstance(tvals[i], float))

availability = osut.availabilitySchedule(model)
schedule = openstudio.model.ScheduleConstant(model)
self.assertTrue(schedule.setValue(22.78)) # reuse cooling setpoint

# Create radiant electric heating.
ht = (openstudio.model.ZoneHVACLowTemperatureRadiantElectric(
Expand Down Expand Up @@ -3606,7 +3659,7 @@ def test26_ulc_blc(self):
# [70, 0, 0]
# [70, 45, 0]
# [ 0, 45, 0]

def test27_polygon_attributes(self):
o = osut.oslg
self.assertEqual(o.status(), 0)
Expand Down Expand Up @@ -5349,15 +5402,15 @@ def test35_facet_retrieval(self):

translator = openstudio.osversion.VersionTranslator()

path = openstudio.path("./tests/files/osms/out/seb2.osm")
path = openstudio.path("./tests/files/osms/out/seb_ext2.osm")
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Harmonizing with Ruby test.

model = translator.loadModel(path)
self.assertTrue(model)
model = model.get()
spaces = model.getSpaces()
surfs = model.getSurfaces()
subs = model.getSubSurfaces()
self.assertEqual(len(surfs), 56)
self.assertEqual(len(subs), 8)
self.assertEqual(len(surfs), 59)
self.assertEqual(len(subs), 14)

# The solution is similar to:
# OpenStudio::Model::Space::findSurfaces(minDegreesFromNorth,
Expand All @@ -5381,15 +5434,15 @@ def test35_facet_retrieval(self):
roofs1 = osut.facets(spaces, "Outdoors", "RoofCeiling", "top")
roofs2 = osut.facets(spaces, "Outdoors", "RoofCeiling", "foo")

self.assertEqual(len(windows), 8)
self.assertEqual(len(skylights), 0)
self.assertEqual(len(walls), 26)
self.assertEqual(len(windows), 11)
self.assertEqual(len(skylights), 3)
self.assertEqual(len(walls), 28)
self.assertFalse(northsouth)
self.assertEqual(len(northeast), 8)
self.assertEqual(len(north), 14)
self.assertEqual(len(floors1a), 4)
self.assertEqual(len(floors1b), 4)
self.assertEqual(len(roofs1), 4)
self.assertEqual(len(roofs1), 5)
self.assertFalse(roofs2)

# Concise variants, same output. In the SEB model, floors face "Ground".
Expand Down Expand Up @@ -5574,7 +5627,7 @@ def test36_slab_generation(self):
self.assertEqual(len(surface.vertices()), 12)
self.assertAlmostEqual(surface.grossArea(), 5 * 20 - 1, places=2)

# --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- #
# --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- #
# Same as previous, yet overlapping 'plate' has both negative dX & dY,
# while XY origin is set at top-right (not bottom-left) corner.
# ____ ____
Expand Down Expand Up @@ -5602,6 +5655,17 @@ def test36_slab_generation(self):
self.assertEqual(o.status(), 0)
del model

# --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- #
# Invalid input case.
plates = ["osut"]
slab = osut.genSlab(plates, z0)
self.assertTrue(o.is_debug())
self.assertEqual(len(o.logs()), 1)
self.assertTrue("str? expecting dict" in o.logs()[0]["message"])
self.assertTrue(isinstance(slab, openstudio.Point3dVector))
self.assertFalse(slab)
self.assertEqual(o.clean(), DBG)
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stress-test: when requested floor plates (input) aren't dictionaries.


def test37_roller_shades(self):
o = osut.oslg
self.assertEqual(o.status(), 0)
Expand Down