Skip to content

Commit 33600d1

Browse files
committed
Completes segments, triads and orientation tests
1 parent 623ebbb commit 33600d1

File tree

2 files changed

+236
-5
lines changed

2 files changed

+236
-5
lines changed

src/osut/osut.py

Lines changed: 140 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1299,6 +1299,141 @@ def isFenestrated(s=None) -> bool:
12991299
return True
13001300

13011301

1302+
# ---- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- ---- #
1303+
# ---- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- ---- #
1304+
# This next set of utilities (~850 lines) help distinguish spaces that are
1305+
# directly vs indirectly CONDITIONED, vs SEMIHEATED. The solution here
1306+
# relies as much as possible on space conditioning categories found in
1307+
# standards like ASHRAE 90.1 and energy codes like the Canadian NECBs.
1308+
#
1309+
# Both documents share many similarities, regardless of nomenclature. There
1310+
# are however noticeable differences between approaches on how a space is
1311+
# tagged as falling into one of the aforementioned categories. First, an
1312+
# overview of 90.1 requirements, with some minor edits for brevity/emphasis:
1313+
#
1314+
# www.pnnl.gov/main/publications/external/technical_reports/PNNL-26917.pdf
1315+
#
1316+
# 3.2.1. General Information - SPACE CONDITIONING CATEGORY
1317+
#
1318+
# - CONDITIONED space: an ENCLOSED space that has a heating and/or
1319+
# cooling system of sufficient size to maintain temperatures suitable
1320+
# for HUMAN COMFORT:
1321+
# - COOLED: cooled by a system >= 10 W/m2
1322+
# - HEATED: heated by a system, e.g. >= 50 W/m2 in Climate Zone CZ-7
1323+
# - INDIRECTLY: heated or cooled via adjacent space(s) provided:
1324+
# - UA of adjacent surfaces > UA of other surfaces
1325+
# or
1326+
# - intentional air transfer from HEATED/COOLED space > 3 ACH
1327+
#
1328+
# ... includes plenums, atria, etc.
1329+
#
1330+
# - SEMIHEATED space: an ENCLOSED space that has a heating system
1331+
# >= 10 W/m2, yet NOT a CONDITIONED space (see above).
1332+
#
1333+
# - UNCONDITIONED space: an ENCLOSED space that is NOT a conditioned
1334+
# space or a SEMIHEATED space (see above).
1335+
#
1336+
# NOTE: Crawlspaces, attics, and parking garages with natural or
1337+
# mechanical ventilation are considered UNENCLOSED spaces.
1338+
#
1339+
# 2.3.3 Modeling Requirements: surfaces adjacent to UNENCLOSED spaces
1340+
# shall be treated as exterior surfaces. All other UNENCLOSED surfaces
1341+
# are to be modeled as is in both proposed and baseline models. For
1342+
# instance, modeled fenestration in UNENCLOSED spaces would not be
1343+
# factored in WWR calculations.
1344+
#
1345+
#
1346+
# Related NECB definitions and concepts, starting with CONDITIONED space:
1347+
#
1348+
# "[...] the temperature of which is controlled to limit variation in
1349+
# response to the exterior ambient temperature by the provision, either
1350+
# DIRECTLY or INDIRECTLY, of heating or cooling [...]". Although criteria
1351+
# differ (e.g., not sizing-based), the general idea is sufficiently similar
1352+
# to ASHRAE 90.1 (e.g. heating and/or cooling based, no distinction for
1353+
# INDIRECTLY conditioned spaces like plenums).
1354+
#
1355+
# SEMIHEATED spaces are described in the NECB (yet not a defined term). The
1356+
# distinction is also based on desired/intended design space setpoint
1357+
# temperatures (here 15°C) - not system sizing criteria. No further treatment
1358+
# is implemented here to distinguish SEMIHEATED from CONDITIONED spaces;
1359+
# notwithstanding the AdditionalProperties tag (described further in this
1360+
# section), it is up to users to determine if a CONDITIONED space is
1361+
# indeed SEMIHEATED or not (e.g. based on MIN/MAX setpoints).
1362+
#
1363+
# The single NECB criterion distinguishing UNCONDITIONED ENCLOSED spaces
1364+
# (such as vestibules) from UNENCLOSED spaces (such as attics) remains the
1365+
# intention to ventilate - or rather to what degree. Regardless, the methods
1366+
# here are designed to process both classifications in the same way, namely
1367+
# by focusing on adjacent surfaces to CONDITIONED (or SEMIHEATED) spaces as
1368+
# part of the building envelope.
1369+
1370+
# In light of the above, OSut methods here are designed without a priori
1371+
# knowledge of explicit system sizing choices or access to iterative
1372+
# autosizing processes. As discussed in greater detail below, methods here
1373+
# are developed to rely on zoning and/or "intended" setpoint temperatures.
1374+
# In addition, OSut methods here cannot distinguish between UNCONDITIONED vs
1375+
# UNENCLOSED spaces from OpenStudio geometry alone. They are henceforth
1376+
# considered synonymous.
1377+
#
1378+
# For an OpenStudio model in an incomplete or preliminary state, e.g. holding
1379+
# fully-formed ENCLOSED spaces WITHOUT thermal zoning information or setpoint
1380+
# temperatures (early design stage assessments of form, porosity or
1381+
# envelope), OpenStudio spaces are considered CONDITIONED by default. This
1382+
# default behaviour may be reset based on the (Space) AdditionalProperties
1383+
# "space_conditioning_category" key (4x possible values), which is relied
1384+
# upon by OpenStudio-Standards:
1385+
#
1386+
# github.com/NREL/openstudio-standards/blob/
1387+
# d2b5e28928e712cb3f137ab5c1ad6d8889ca02b7/lib/openstudio-standards/
1388+
# standards/Standards.Space.rb#L1604C5-L1605C1
1389+
#
1390+
# OpenStudio-Standards recognizes 4x possible value strings:
1391+
# - "NonResConditioned"
1392+
# - "ResConditioned"
1393+
# - "Semiheated"
1394+
# - "Unconditioned"
1395+
#
1396+
# OSut maintains existing "space_conditioning_category" key/value pairs
1397+
# intact. Based on these, OSut methods may return related outputs:
1398+
#
1399+
# "space_conditioning_category" | OSut status | heating °C | cooling °C
1400+
# ------------------------------- ------------- ---------- ----------
1401+
# - "NonResConditioned" CONDITIONED 21.0 24.0
1402+
# - "ResConditioned" CONDITIONED 21.0 24.0
1403+
# - "Semiheated" SEMIHEATED 15.0 NA
1404+
# - "Unconditioned" UNCONDITIONED NA NA
1405+
#
1406+
# OSut also looks up another (Space) AdditionalProperties 'key',
1407+
# "indirectlyconditioned" to flag plenum or occupied spaces indirectly
1408+
# conditioned with transfer air only. The only accepted 'value' for an
1409+
# "indirectlyconditioned" 'key' is the name (string) of another (linked)
1410+
# space, e.g.:
1411+
#
1412+
# "indirectlyconditioned" space | linked space, e.g. "core_space"
1413+
# ------------------------------- ---------------------------------------
1414+
# return air plenum occupied space below
1415+
# supply air plenum occupied space above
1416+
# dead air space (not a plenum) nearby occupied space
1417+
#
1418+
# OSut doesn't validate whether the "indirectlyconditioned" space is actually
1419+
# adjacent to its linked space. It nonetheless relies on the latter's
1420+
# conditioning category (e.g. CONDITIONED, SEMIHEATED) to determine
1421+
# anticipated ambient temperatures in the former. For instance, an
1422+
# "indirectlyconditioned"-tagged return air plenum linked to a SEMIHEATED
1423+
# space is considered as free-floating in terms of cooling, and unlikely to
1424+
# have ambient conditions below 15°C under heating (winter) design
1425+
# conditions. OSut will associate this plenum to a 15°C heating setpoint
1426+
# temperature. If the SEMIHEATED space instead has a heating setpoint
1427+
# temperature of 7°C, then OSut will associate a 7°C heating setpoint to this
1428+
# plenum.
1429+
#
1430+
# Even with a (more developed) OpenStudio model holding valid space/zone
1431+
# setpoint temperatures, OSut gives priority to these AdditionalProperties.
1432+
# For instance, a CONDITIONED space can be considered INDIRECTLYCONDITIONED,
1433+
# even if its zone thermostat has a valid heating and/or cooling setpoint.
1434+
# This is in sync with OpenStudio-Standards' method
1435+
# "space_conditioning_category()".
1436+
13021437
def hasAirLoopsHVAC(model=None) -> bool:
13031438
"""Validates if model has zones with HVAC air loops.
13041439
@@ -1668,14 +1803,14 @@ def maxHeatScheduledSetpoint(zone=None) -> dict:
16681803

16691804

16701805
def hasHeatingTemperatureSetpoints(model=None):
1671-
"""Confirms if model has zones with valid heating temperature setpoints.
1806+
"""Confirms if model has zones with valid heating setpoint temperature.
16721807
16731808
Args:
16741809
model (openstudio.model.Model):
16751810
An OpenStudio model.
16761811
16771812
Returns:
1678-
bool: Whether model holds valid heating temperature setpoints.
1813+
bool: Whether model holds valid heating setpoint temperatures.
16791814
False: If invalid inputs (see logs).
16801815
"""
16811816
mth = "osut.hasHeatingTemperatureSetpoints"
@@ -1850,14 +1985,14 @@ def minCoolScheduledSetpoint(zone=None):
18501985

18511986

18521987
def hasCoolingTemperatureSetpoints(model=None):
1853-
"""Confirms if model has zones with valid cooling temperature setpoints.
1988+
"""Confirms if model has zones with valid cooling setpoint temperatures.
18541989
18551990
Args:
18561991
model (openstudio.model.Model):
18571992
An OpenStudio model.
18581993
18591994
Returns:
1860-
bool: Whether model holds valid cooling temperature setpoints.
1995+
bool: Whether model holds valid cooling setpoint temperatures.
18611996
False: If invalid inputs (see logs).
18621997
"""
18631998
mth = "osut.hasCoolingTemperatureSetpoints"
@@ -3089,7 +3224,7 @@ def triads(pts=None, co=False) -> openstudio.Point3dVectorVector:
30893224

30903225
i3 = i2 + 1
30913226
if i3 == len(pts): i3 = 0
3092-
3227+
30933228
p2 = pts[i2]
30943229
p3 = pts[i3]
30953230

tests/test_osut.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3098,6 +3098,102 @@ def test25_segments_triads_orientation(self):
30983098
pt = osut.lineIntersection([p0, p2], [p6, p4])
30993099
self.assertTrue(osut.areSame(pt, p5))
31003100

3101+
# Point ENTIRELY within (vs outside) a polygon.
3102+
self.assertFalse(osut.isPointWithinPolygon(p0, [p0, p1, p2, p3], True))
3103+
self.assertFalse(osut.isPointWithinPolygon(p1, [p0, p1, p2, p3], True))
3104+
self.assertFalse(osut.isPointWithinPolygon(p2, [p0, p1, p2, p3], True))
3105+
self.assertFalse(osut.isPointWithinPolygon(p3, [p0, p1, p2, p3], True))
3106+
self.assertFalse(osut.isPointWithinPolygon(p4, [p0, p1, p2, p3]))
3107+
self.assertTrue(osut.isPointWithinPolygon(p5, [p0, p1, p2, p3]))
3108+
self.assertFalse(osut.isPointWithinPolygon(p6, [p0, p1, p2, p3]))
3109+
self.assertTrue(osut.isPointWithinPolygon(p7, [p0, p1, p2, p3]))
3110+
self.assertEqual(o.status(), 0)
3111+
3112+
# --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- #
3113+
# Test invalid plane.
3114+
vtx = openstudio.Point3dVector()
3115+
vtx.append(openstudio.Point3d(20, 0, 10))
3116+
vtx.append(openstudio.Point3d( 0, 0, 10))
3117+
vtx.append(openstudio.Point3d( 0, 0, 0))
3118+
vtx.append(openstudio.Point3d(20, 1, 0))
3119+
3120+
self.assertEqual(len(osut.poly(vtx)), 0)
3121+
self.assertTrue(o.is_error())
3122+
self.assertEqual(len(o.logs()), 1)
3123+
self.assertTrue("Empty 'plane'" in o.logs()[0]["message"])
3124+
self.assertEqual(o.clean(), DBG)
3125+
3126+
# Self-intersecting polygon. If reactivated, OpenStudio logs to stdout:
3127+
# [utilities.Transformation]
3128+
# <1> Cannot compute outward normal for vertices
3129+
# vtx = openstudio.Point3dVector()
3130+
# vtx.append(openstudio.Point3d(20, 0, 10))
3131+
# vtx.append(openstudio.Point3d( 0, 0, 10))
3132+
# vtx.append(openstudio.Point3d(20, 0, 0))
3133+
# vtx.append(openstudio.Point3d( 0, 0, 0))
3134+
#
3135+
# Original polygon remains unaltered.
3136+
# self.assertEqual(len(osut.poly(vtx)), 4)
3137+
# self.assertEqual(o.status(), 0)
3138+
# self.assertEqual(o.clean(), DBG)
3139+
3140+
# Regular polygon, counterclockwise yet not UpperLeftCorner (ULC).
3141+
vtx = openstudio.Point3dVector()
3142+
vtx.append(openstudio.Point3d(20, 0, 10))
3143+
vtx.append(openstudio.Point3d( 0, 0, 10))
3144+
vtx.append(openstudio.Point3d( 0, 0, 0))
3145+
3146+
sgs = osut.segments(vtx)
3147+
self.assertTrue(isinstance(sgs, openstudio.Point3dVectorVector))
3148+
self.assertEqual(len(sgs), 3)
3149+
3150+
for i, sg in enumerate(sgs):
3151+
if not osut.shareXYZ(sg, "x", sg[0].x()):
3152+
vplane = osut.verticalPlane(sg[0], sg[1])
3153+
self.assertTrue(isinstance(vplane, openstudio.Plane))
3154+
3155+
# --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- #
3156+
# Test when alignFace switches solution when surfaces are nearly flat,
3157+
# i.e. when dot product of surface normal vs zenith > 0.99.
3158+
# (see openstudio.Transformation.alignFace)
3159+
origin = openstudio.Point3d(0,0,0)
3160+
originZ = openstudio.Point3d(0,0,1)
3161+
zenith = originZ - origin
3162+
3163+
# 1st surface, nearly horizontal.
3164+
vtx = openstudio.Point3dVector()
3165+
vtx.append(openstudio.Point3d( 2,10, 0.0))
3166+
vtx.append(openstudio.Point3d( 6, 4, 0.0))
3167+
vtx.append(openstudio.Point3d( 8, 8, 0.5))
3168+
normal = openstudio.getOutwardNormal(vtx).get()
3169+
self.assertGreater(abs(zenith.dot(normal)), 0.99)
3170+
self.assertTrue(osut.facingUp(vtx))
3171+
3172+
aligned = list(osut.poly(vtx, False, False, False, True, "ulc"))
3173+
matches = []
3174+
3175+
for pt in aligned:
3176+
if osut.areSame(pt, origin): matches.append(pt)
3177+
3178+
self.assertEqual(len(matches), 0)
3179+
3180+
# 2nd surface (nearly identical, yet too slanted to be flat.
3181+
vtx = openstudio.Point3dVector()
3182+
vtx.append(openstudio.Point3d( 2,10, 0.0))
3183+
vtx.append(openstudio.Point3d( 6, 4, 0.0))
3184+
vtx.append(openstudio.Point3d( 8, 8, 0.6))
3185+
normal = openstudio.getOutwardNormal(vtx).get()
3186+
self.assertLess(abs(zenith.dot(normal)), 0.99)
3187+
self.assertFalse(osut.facingUp(vtx))
3188+
3189+
aligned = list(osut.poly(vtx, False, False, False, True, "ulc"))
3190+
matches = []
3191+
3192+
for pt in aligned:
3193+
if osut.areSame(pt, origin): matches.append(pt)
3194+
3195+
self.assertEqual(len(matches), 1)
3196+
31013197
def test26_ulc_blc(self):
31023198
o = osut.oslg
31033199
self.assertEqual(o.status(), 0)

0 commit comments

Comments
 (0)