From a3e8b7cfef26d40ae4297700b72b26408557f6aa Mon Sep 17 00:00:00 2001 From: brgix Date: Tue, 8 Jul 2025 13:46:06 -0400 Subject: [PATCH 01/49] Adds geometry-related functions & tests --- pyproject.toml | 2 +- tests/test_osut.py | 6 +----- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f01f4a3..0a24a3d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "osut" -version = "0.6.0a1" +version = "0.6.0a2" description = "OpenStudio SDK utilities for Python" readme = "README.md" requires-python = ">=3.2" diff --git a/tests/test_osut.py b/tests/test_osut.py index 1f24dad..c09047d 100644 --- a/tests/test_osut.py +++ b/tests/test_osut.py @@ -27,14 +27,10 @@ # OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -import sys -sys.path.append("./src/osut") - -import os import math import unittest import openstudio -import osut +from src.osut import osut DBG = osut.CN.DBG INF = osut.CN.INF From aad82a5d53b3a1628ed19be12f56ae917eba9524 Mon Sep 17 00:00:00 2001 From: brgix Date: Tue, 8 Jul 2025 15:42:15 -0400 Subject: [PATCH 02/49] Implements basic 3DVector functions (untested) --- src/osut/osut.py | 312 ++++++++++++++++++++++++++++++++++++++++++++- tests/test_osut.py | 4 +- 2 files changed, 312 insertions(+), 4 deletions(-) diff --git a/src/osut/osut.py b/src/osut/osut.py index 66b93c2..66bacd0 100644 --- a/src/osut/osut.py +++ b/src/osut/osut.py @@ -209,6 +209,51 @@ def uo() -> dict: return _uo +def each_cons(it, n): + """A proxy for Ruby Enumerable's 'each_cons(n)' method. + + Args: + it: + A sequence. + n (int): + The number of sequential items in sequence. + + Returns: + tuple: n-sized sequenced items. + + """ + # see: docs.ruby-lang.org/en/3.2/Enumerable.html#method-i-each_cons + # + # James Wong's Python workaround implementation: + # stackoverflow.com/questions/5878403/python-equivalent-to-rubys-each-cons + + # Convert as iterator. + it = iter(it) + deq = collections.deque() + + # Insert first n items to a list first. + for _ in range(n): + try: + deq.append(next(it)) + except StopIteration: + for _ in range(n - len(deq)): + deq.append(None) + yield tuple(deq) + return + + yield tuple(deq) + + # Main loop. + while True: + try: + val = next(it) + except StopIteration: + return + deq.popleft() + deq.append(val) + yield tuple(deq) + + def genConstruction(model=None, specs=dict()): """Generates an OpenStudio multilayered construction, + materials if needed. @@ -2395,7 +2440,7 @@ def trueNormal(s=None, r=0): Returns: openstudio.Vector3d: A surface's true normal vector. - None : If invalid input (see logs). + None: If invalid input (see logs). """ mth = "osut.trueNormal" @@ -2486,7 +2531,7 @@ def to_p3Dv(pts=None) -> openstudio.Point3dVector: return v -def is_same_vtx(s1=None, s2=None, indexed=True) -> bool: +def is_same(s1=None, s2=None, indexed=True) -> bool: """Returns True if 2 sets of OpenStudio 3D points are nearly equal. Args: @@ -2545,6 +2590,269 @@ def is_same_vtx(s1=None, s2=None, indexed=True) -> bool: return True +def holds(pts=None, p1=None) -> bool: + """Returns True if an OpenStudio 3D point is part of a set of 3D points. + + Args: + pts (openstudio.Point3dVector): + An OpenStudio vector of 3D points. + p1 (openstudio.Point3d): + An OpenStudio 3D point. + + Returns: + bool: Whether part of a set of 3D points. + False: If invalid inputs (see logs). + + """ + mth = "osut.holds" + pts = to_p3Dv(pts) + cl = openstudio.Point3d + + if not isinstance(p1, cl): + return oslg.mismatch("point", p1, cl, mth, CN.DBG, False) + + for pt in pts: + if is_same_vtx(p1, pt): return True + + return False + + +def nearest(pts=None, p01=None): + """Returns the vector index of an OpenStudio 3D point nearest to a point of + reference, e.g. grid origin. If left unspecified, the method systematically + returns the bottom-left corner (BLC) of any horizontal set. If more than + one point fits the initial criteria, the method relies on deterministic + sorting through triangulation. + + Args: + pts (openstudio.Point3dVector): + An OpenStudio vector of 3D points. + p1 (openstudio.Point3d): + An OpenStudio 3D point of reference. + + Returns: + int: Vector index of nearest point to point of reference. + None: If invalid input (see logs). + + """ + mth = "osut.nearest" + l = 100 + d01 = 10000 + d02 = 0 + d03 = 0 + idx = None + pts = to_p3Dv(pts) + if not pts: return idx + + p03 = openstudio.Point3d( l,-l,-l) + p02 = openstudio.Point3d( l, l, l) + + if not p01: p01 = openstudio.Point3d(-l,-l,-l) + + if not isinstance(p01, openstudio.Point3d): + return oslg.mismatch("point", p01, cl, mth) + + for i, pt in enumerate(pts): + if is_same_vtx(pt, p01): return i + + for i, pt in enumerate(pts): + length01 = (pt - p01).length() + length02 = (pt - p02).length() + length03 = (pt - p03).length() + + if round(length01, 2) == round(d01, 2): + if round(length02, 2) == round(d02, 2): + if round(length03, 2) > round(d03, 2): + idx = i + d03 = length03 + elif round(length02, 2) > round(d02, 2): + idx = i + d03 = length03 + d02 = length02 + elif round(length01, 2) < round(d01, 2): + idx = i + d01 = length01 + d02 = length02 + d03 = length03 + + return idx + + +def farthest(pts=None, p01=None): + """Returns the vector index of an OpenStudio 3D point farthest from a point + of reference, e.g. grid origin. If left unspecified, the method + systematically returns the top-right corner (TRC) of any horizontal set. If + more than one point fits the initial criteria, the method relies on + deterministic sorting through triangulation. + + Args: + pts (openstudio.Point3dVector): + An OpenStudio vector of 3D points. + p1 (openstudio.Point3d): + An OpenStudio 3D point of reference. + + Returns: + int: Vector index of farthest point from point of reference. + None: If invalid input (see logs). + + """ + mth = "osut.farthest" + l = 100 + d01 = 0 + d02 = 10000 + d03 = 10000 + idx = None + pts = to_p3Dv(pts) + if not pts: return idx + + p03 = openstudio.Point3d( l,-l,-l) + p02 = openstudio.Point3d( l, l, l) + + if not p01: p01 = openstudio.Point3d(-l,-l,-l) + + if not isinstance(p01, openstudio.Point3d): + return oslg.mismatch("point", p01, cl, mth) + + for i, pt in enumerate(pts): + if is_same_vtx(pt, p01): continue + + length01 = (pt - p01).length() + length02 = (pt - p02).length() + length03 = (pt - p03).length() + + if round(length01, 2) == round(d01, 2): + if round(length02, 2) == round(d02, 2): + if round(length03, 2) < round(d03, 2): + idx = i + d03 = length03 + elif round(length02, 2) < round(d02, 2): + idx = i + d03 = length03 + d02 = length02 + elif round(length01, 2) > round(d01, 2): + idx = i + d01 = length01 + d02 = length02 + d03 = length03 + + return idx + + +def flatten(pts=None, axs="z", val=0) -> openstudio.Point3dVector: + """Flattens OpenStudio 3D points vs X, Y or Z axes. + + Args: + pts (openstudio.Point3dVector): + An OpenStudio vector of 3D points. + axs (str): + Selected "x", "y" or "z" axis. + val (float): + Axis value. + + Returns: + openstudio.Point3dVector: flattened points (see logs if empty) + """ + mth = "osut.flatten" + pts = to_p3Dv(pts) + v = openstudio.Point3dVector() + + try: + val = float(val) + except: + return oslg.mismatch("val", val, float, mth, CN.DBG, v) + + try: + axs = str(axs) + except: + return oslg.mismatch("axis (XYZ?)", axs, str, mth, CN.DBG, v) + + if axs.lower() == "x": + for pt in pts: v.append(openstudio.Point3d(val, pt.y(), pt.z())) + elif axs.lower() == "y": + for pt in pts: v.append(openstudio.Point3d(pt.x(), val, pt.z())) + elif axs.lower() == "z": + for pt in pts: v.append(openstudio.Point3d(pt.x(), pt.y(), val)) + else: + return oslg.invalid("axis (XYZ?)", mth, 2, CN.DBG, v) + + return v + + +def shareXYZ(pts=None, axs="z", val=0) -> bool: + """Validates whether 3D points share X, Y or Z coordinates. + + Args: + pts (openstudio.Point3dVector): + An OpenStudio vector of 3D points. + axs (str): + Selected "x", "y" or "z" axis. + val (float): + Axis value. + + Returns: + bool: If points share X, Y or Z coordinates. + False: If invalid inputs (see logs). + + """ + mth = "osut.shareXYZ" + pts = to_p3Dv(pts) + if not pts: return False + + try: + val = float(val) + except: + return oslg.mismatch("val", val, float, mth, CN.DBG, False) + + try: + axs = str(axs) + except: + return oslg.mismatch("axis (XYZ?)", axs, str, mth, CN.DBG, False) + + if axs.lower() == "x": + for pt in pts: + if abs(pt.x() - val) > CN.TOL: return False + elif axs.lower() == "y": + for pt in pts: + if abs(pt.y() - val) > CN.TOL: return False + elif axs.lower() == "z": + for pt in pts: + if abs(pt.x() - val) > CN.TOL: return False + else: + return invalid("axis", mth, 2, CN.DBG, False) + + return True + + +def nextUp(pts=None, pt=None): + """Returns next sequential point in an OpenStudio 3D point vector. + + Args: + pts (openstudio.Point3dVector): + An OpenStudio vector of 3D points. + p1 (openstudio.Point3d): + An OpenStudio 3D point of reference. + + Returns: + openstudio.Point3d: The next sequential 3D point. + None: If invalid inputs (see logs). + + """ + mth = "osut.nextUP" + pts = to_p3Dv(pts) + cl = openstudio.Point3d + + if not isinstance(pt, cl): + return oslg.mismatch("point", pt, cl, mth) + + if len(pts) < 2: + return oslg.invalid("points (2+)", mth, 1, CN.WRN) + + for pair in each_cons(pts, 2): + if is_same(pair[0], pt): return pair[-1] + + return pts[0] + + def facets(spaces=[], boundary="all", type="all", sides=[]) -> list: """Returns an array of OpenStudio space surfaces or subsurfaces that match criteria, e.g. exterior, north-east facing walls in hotel "lobby". Note diff --git a/tests/test_osut.py b/tests/test_osut.py index c09047d..0455f02 100644 --- a/tests/test_osut.py +++ b/tests/test_osut.py @@ -784,7 +784,7 @@ def test07_construction_thickness(self): # Same vertex sequence? Should be in reverse order. for i, vtx in enumerate(adj.vertices()): - self.assertTrue(osut.is_same_vtx(vtx, s.vertices()[i])) + self.assertTrue(osut.is_same(vtx, s.vertices()[i])) self.assertEqual(adj.surfaceType(), "RoofCeiling") self.assertEqual(s.surfaceType(), "RoofCeiling") @@ -798,7 +798,7 @@ def test07_construction_thickness(self): rvtx.reverse() for i, vtx in enumerate(rvtx): - self.assertTrue(osut.is_same_vtx(vtx, s.vertices()[i])) + self.assertTrue(osut.is_same(vtx, s.vertices()[i])) # After the fix. if version >= 350: From c77bcf1fb964363c165d8197086c1eb66c746dd5 Mon Sep 17 00:00:00 2001 From: brgix Date: Tue, 8 Jul 2025 19:13:35 -0400 Subject: [PATCH 03/49] Fixes is_same ('0' as valid list index) --- src/osut/osut.py | 86 ++++++++++++++++++++++------------------------ tests/test_osut.py | 74 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 113 insertions(+), 47 deletions(-) diff --git a/src/osut/osut.py b/src/osut/osut.py index 66bacd0..280d1c9 100644 --- a/src/osut/osut.py +++ b/src/osut/osut.py @@ -889,7 +889,7 @@ def holdsConstruction(set=None, base=None, gr=False, ex=False, type=""): else: return oslg.invalid("surface type", mth, 5, CN.DBG, False) - if not c: return False + if c is None: return False if type in t1: if type == "roofceiling": @@ -1554,20 +1554,20 @@ def maxHeatScheduledSetpoint(zone=None) -> dict: if coil.heatingControlTemperatureSchedule(): sched = coil.heatingControlTemperatureSchedule().get() - if not sched: continue + if sched is None: continue if sched.to_ScheduleRuleset(): sched = sched.to_ScheduleRuleset().get() maximum = scheduleRulesetMinMax(sched)["max"] if maximum: - if not res["spt"] or res["spt"] < maximum: + if res["spt"] is None or res["spt"] < maximum: res["spt"] = maximum dd = sched.winterDesignDaySchedule() if dd.values(): - if not res["spt"] or res["spt"] < max(dd.values()): + if res["spt"] is None or res["spt"] < max(dd.values()): res["spt"] = max(dd.values()) if sched.to_ScheduleConstant(): @@ -1575,7 +1575,7 @@ def maxHeatScheduledSetpoint(zone=None) -> dict: maximum = scheduleConstantMinMax(sched)["max"] if maximum: - if not res["spt"] or res["spt"] < maximum: + if res["spt"] is None or res["spt"] < maximum: res["spt"] = maximum if sched.to_ScheduleCompact(): @@ -1583,7 +1583,7 @@ def maxHeatScheduledSetpoint(zone=None) -> dict: maximum = scheduleCompactMinMax(sched)["max"] if maximum: - if not res["spt"] or res["spt"] < maximum: + if res["spt"] is None or res["spt"] < maximum: res["spt"] = maximum if sched.to_ScheduleInterval(): @@ -1591,7 +1591,7 @@ def maxHeatScheduledSetpoint(zone=None) -> dict: maximum = scheduleIntervalMinMax(sched)["max"] if maximum: - if not res["spt"] or res["spt"] < maximum: + if res["spt"] is None or res["spt"] < maximum: res["spt"] = maximum if not zone.thermostat(): return res @@ -1616,13 +1616,13 @@ def maxHeatScheduledSetpoint(zone=None) -> dict: maximum = scheduleRulesetMinMax(sched)["max"] if maximum: - if not res["spt"] or res["spt"] < maximum: + if res["spt"] is None or res["spt"] < maximum: res["spt"] = maximum dd = sched.winterDesignDaySchedule() if dd.values(): - if not res["spt"] or res["spt"] < max(dd.values()): + if res["spt"] is None or res["spt"] < max(dd.values()): res["spt"] = max(dd.values()) if sched.to_ScheduleConstant(): @@ -1630,7 +1630,7 @@ def maxHeatScheduledSetpoint(zone=None) -> dict: maximum = scheduleConstantMinMax(sched)["max"] if maximum: - if not res["spt"] or res["spt"] < maximum: + if res["spt"] is None or res["spt"] < maximum: res["spt"] = maximum if sched.to_ScheduleCompact(): @@ -1638,7 +1638,7 @@ def maxHeatScheduledSetpoint(zone=None) -> dict: maximum = scheduleCompactMinMax(sched)["max"] if maximum: - if not res["spt"] or res["spt"] < maximum: + if res["spt"] is None or res["spt"] < maximum: res["spt"] = maximum if sched.to_ScheduleInterval(): @@ -1646,7 +1646,7 @@ def maxHeatScheduledSetpoint(zone=None) -> dict: maximum = scheduleIntervalMinMax(sched)["max"] if maximum: - if not res["spt"] or res["spt"] < maximum: + if res["spt"] is None or res["spt"] < maximum: res["spt"] = maximum if sched.to_ScheduleYear(): @@ -1657,7 +1657,7 @@ def maxHeatScheduledSetpoint(zone=None) -> dict: dd = week.winterDesignDaySchedule().get() if dd.values(): - if not res["spt"] or res["spt"] < max(dd.values()): + if res["spt"] is None or res["spt"] < max(dd.values()): res["spt"] = max(dd.values()) return res @@ -1734,20 +1734,20 @@ def minCoolScheduledSetpoint(zone=None): if coil.coolingControlTemperatureSchedule(): sched = coil.coolingControlTemperatureSchedule().get() - if not sched: continue + if sched is None: continue if sched.to_ScheduleRuleset(): sched = sched.to_ScheduleRuleset().get() minimum = scheduleRulesetMinMax(sched)["min"] if minimum: - if not res["spt"] or res["spt"] > minimum: + if res["spt"] is None or res["spt"] > minimum: res["spt"] = minimum dd = sched.summerDesignDaySchedule() if dd.values(): - if not res["spt"] or res["spt"] > min(dd.values()): + if res["spt"] is None or res["spt"] > min(dd.values()): res["spt"] = min(dd.values()) if sched.to_ScheduleConstant(): @@ -1755,7 +1755,7 @@ def minCoolScheduledSetpoint(zone=None): minimum = scheduleConstantMinMax(sched)["min"] if minimum: - if not res["spt"] or res["spt"] > minimum: + if res["spt"] is None or res["spt"] > minimum: res["spt"] = minimum if sched.to_ScheduleCompact(): @@ -1763,7 +1763,7 @@ def minCoolScheduledSetpoint(zone=None): minimum = scheduleCompactMinMax(sched)["min"] if minimum: - if not res["spt"] or res["spt"] > minimum: + if res["spt"] is None or res["spt"] > minimum: res["spt"] = minimum if sched.to_ScheduleInterval(): @@ -1771,7 +1771,7 @@ def minCoolScheduledSetpoint(zone=None): minimum = scheduleIntervalMinMax(sched)["min"] if minimum: - if not res["spt"] or res["spt"] > minimum: + if res["spt"] is None or res["spt"] > minimum: res["spt"] = minimum if not zone.thermostat(): return res @@ -1797,13 +1797,13 @@ def minCoolScheduledSetpoint(zone=None): minimum = scheduleRulesetMinMax(sched)["min"] if minimum: - if not res["spt"] or res["spt"] > minimum: + if res["spt"] is None or res["spt"] > minimum: res["spt"] = minimum dd = sched.summerDesignDaySchedule() if dd.values(): - if not res["spt"] or res["spt"] > min(dd.values()): + if res["spt"] is None or res["spt"] > min(dd.values()): res["spt"] = min(dd.values()) if sched.to_ScheduleConstant(): @@ -1811,7 +1811,7 @@ def minCoolScheduledSetpoint(zone=None): minimum = scheduleConstantMinMax(sched)[:min] if minimum: - if not res["spt"] or res["spt"] > minimum: + if res["spt"] is None or res["spt"] > minimum: res["spt"] = minimum if sched.to_ScheduleCompact(): @@ -1819,7 +1819,7 @@ def minCoolScheduledSetpoint(zone=None): minimum = scheduleCompactMinMax(sched)["min"] if minimum: - if not res["spt"] or res["spt"] > minimum: + if res["spt"] is None or res["spt"] > minimum: res["spt"] = minimum if sched.to_ScheduleInterval(): @@ -1827,7 +1827,7 @@ def minCoolScheduledSetpoint(zone=None): minimum = scheduleIntervalMinMax(sched)["min"] if minimum: - if not res["spt"] or res["spt"] > minimum: + if res["spt"] is None or res["spt"] > minimum: res["spt"] = minimum if sched.to_ScheduleYear(): @@ -1838,7 +1838,7 @@ def minCoolScheduledSetpoint(zone=None): dd = week.summerDesignDaySchedule().get() if dd.values(): - if not res["spt"] or res["spt"] < min(dd.values()): + if res["spt"] is None or res["spt"] < min(dd.values()): res["spt"] = min(dd.values()) return res @@ -2098,7 +2098,7 @@ def setpoints(space=None): cnd = None # 2. Check instead OSut's INDIRECTLYCONDITIONED (parent space) link. - if not cnd: + if cnd is None: id = space.additionalProperties().getFeatureAsString(tg2) if id: @@ -2273,7 +2273,7 @@ def availabilitySchedule(model=None, avl=""): limits = l - if not limits: + if limits is None: limits = openstudio.model.ScheduleTypeLimits(model) limits.setName("HVAC Operation ScheduleTypeLimits") limits.setLowerLimitValue(0) @@ -2418,7 +2418,7 @@ def transforms(group=None) -> dict: res = dict(t=None, r=None) cl = openstudio.model.PlanarSurfaceGroup - if isinstance(group, cl): + if not isinstance(group, cl): return oslg.mismatch("group", group, cl, mth, CN.DBG, res) mdl = group.model() @@ -2555,25 +2555,23 @@ def is_same(s1=None, s2=None, indexed=True) -> bool: if not isinstance(indexed, bool): indexed = True if indexed: - xOK = abs(s1[0].x() - s2[0].x()) < CN.TOL - yOK = abs(s1[0].y() - s2[0].y()) < CN.TOL - zOK = abs(s1[0].z() - s2[0].z()) < CN.TOL - - if xOK and yOK and zOK and len(s1) == 1: - return True + if len(s1) == 1: + if abs(s1[0].x() - s2[0].x()) > CN.TOL: return False + if abs(s1[0].y() - s2[0].y()) > CN.TOL: return False + if abs(s1[0].z() - s2[0].z()) > CN.TOL: return False else: indx = None for i, pt in enumerate(s2): - if indx: continue + if indx: break - xOK = abs(s1[0].x() - s2[i].x()) < CN.TOL - yOK = abs(s1[0].y() - s2[i].y()) < CN.TOL - zOK = abs(s1[0].z() - s2[i].z()) < CN.TOL + if abs(s1[0].x() - s2[i].x()) > CN.TOL: continue + if abs(s1[0].y() - s2[i].y()) > CN.TOL: continue + if abs(s1[0].z() - s2[i].z()) > CN.TOL: continue - if xOK and yOK and zOK: indx = i + indx = i - if not indx: return False + if indx is None: return False s2 = collections.deque(s2) s2.rotate(indx) @@ -2581,11 +2579,9 @@ def is_same(s1=None, s2=None, indexed=True) -> bool: # openstudio.isAlmostEqual3dPt(p1, p2, TOL) # ... from v350 onwards. for i in range(len(s1)): - xOK = abs(s1[i].x() - s2[i].x()) < CN.TOL - yOK = abs(s1[i].y() - s2[i].y()) < CN.TOL - zOK = abs(s1[i].z() - s2[i].z()) < CN.TOL - - if not xOK or not yOK or not zOK: return False + if abs(s1[i].x() - s2[i].x()) > CN.TOL: return False + if abs(s1[i].y() - s2[i].y()) > CN.TOL: return False + if abs(s1[i].z() - s2[i].z()) > CN.TOL: return false return True diff --git a/tests/test_osut.py b/tests/test_osut.py index 0455f02..7bad573 100644 --- a/tests/test_osut.py +++ b/tests/test_osut.py @@ -2268,8 +2268,78 @@ def test21_availability_schedules(self): del(model) - # def test22_model_transformation(self): - # + def test22_model_transformation(self): + o = osut.oslg + self.assertEqual(o.status(), 0) + self.assertEqual(o.reset(DBG), DBG) + self.assertEqual(o.level(), DBG) + self.assertEqual(o.status(), 0) + translator = openstudio.osversion.VersionTranslator() + + # Successful test. + path = openstudio.path("./tests/files/osms/out/seb2.osm") + model = translator.loadModel(path) + self.assertTrue(model) + model = model.get() + + for space in model.getSpaces(): + tr = osut.transforms(space) + self.assertTrue(isinstance(tr, dict)) + self.assertTrue("t" in tr) + self.assertTrue("r" in tr) + self.assertTrue(isinstance(tr["t"], openstudio.Transformation)) + self.assertAlmostEqual(tr["r"], 0, places=2) + + # Invalid input test. + self.assertEqual(o.status(), 0) + m1 = "'group' NoneType? expecting PlanarSurfaceGroup (osut.transforms)" + tr = osut.transforms(None) + self.assertTrue(isinstance(tr, dict)) + self.assertTrue("t" in tr) + self.assertTrue("r" in tr) + self.assertFalse(tr["t"]) + self.assertFalse(tr["r"]) + self.assertTrue(o.is_debug()) + self.assertEqual(len(o.logs()), 1) + self.assertEqual(o.logs()[0]["message"], m1) + self.assertEqual(o.clean(), DBG) + + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + # Realignment of flat surfaces. + vtx = openstudio.Point3dVector() + vtx.append(openstudio.Point3d( 1, 4, 0)) + vtx.append(openstudio.Point3d( 2, 2, 0)) + vtx.append(openstudio.Point3d( 6, 4, 0)) + vtx.append(openstudio.Point3d( 5, 6, 0)) + + origin = vtx[1] + hyp = (origin - vtx[0]).length() + hyp2 = (origin - vtx[2]).length() + right = openstudio.Point3d(origin.x()+10, origin.y(), origin.z() ) + zenith = openstudio.Point3d(origin.x(), origin.y(), origin.z()+10) + seg = vtx[2] - origin + axis = zenith - origin + droite = right - origin + radians = openstudio.getAngle(droite, seg) + degrees = openstudio.radToDeg(radians) + self.assertAlmostEqual(degrees, 26.565, places=3) + + r = openstudio.Transformation.rotation(origin, axis, radians) + a = r.inverse() * vtx + + self.assertTrue(osut.is_same(a[1], vtx[1])) + self.assertAlmostEqual(a[0].x() - a[1].x(), 0) + self.assertAlmostEqual(a[2].x() - a[1].x(), hyp2) + self.assertAlmostEqual(a[3].x() - a[2].x(), 0) + self.assertAlmostEqual(a[0].y() - a[1].y(), hyp) + self.assertAlmostEqual(a[2].y() - a[1].y(), 0) + self.assertAlmostEqual(a[3].y() - a[1].y(), hyp) + + pts = r * a + self.assertTrue(osut.is_same(pts, vtx)) + + # ... to be completed later. + # def test23_fits_overlaps(self): # # def test24_triangulation(self): From e84240617a117c3c0bd269572e2e8497ee0ebd4d Mon Sep 17 00:00:00 2001 From: brgix Date: Wed, 9 Jul 2025 12:34:13 -0400 Subject: [PATCH 04/49] Implements width/height functions, tests triangulation --- src/osut/osut.py | 54 ++++++++++++++++++++++++++++++---- tests/test_osut.py | 72 ++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 113 insertions(+), 13 deletions(-) diff --git a/src/osut/osut.py b/src/osut/osut.py index 280d1c9..5b9c0fc 100644 --- a/src/osut/osut.py +++ b/src/osut/osut.py @@ -2531,7 +2531,7 @@ def to_p3Dv(pts=None) -> openstudio.Point3dVector: return v -def is_same(s1=None, s2=None, indexed=True) -> bool: +def are_same(s1=None, s2=None, indexed=True) -> bool: """Returns True if 2 sets of OpenStudio 3D points are nearly equal. Args: @@ -2608,7 +2608,7 @@ def holds(pts=None, p1=None) -> bool: return oslg.mismatch("point", p1, cl, mth, CN.DBG, False) for pt in pts: - if is_same_vtx(p1, pt): return True + if are_same_vtx(p1, pt): return True return False @@ -2649,7 +2649,7 @@ def nearest(pts=None, p01=None): return oslg.mismatch("point", p01, cl, mth) for i, pt in enumerate(pts): - if is_same_vtx(pt, p01): return i + if are_same_vtx(pt, p01): return i for i, pt in enumerate(pts): length01 = (pt - p01).length() @@ -2710,7 +2710,7 @@ def farthest(pts=None, p01=None): return oslg.mismatch("point", p01, cl, mth) for i, pt in enumerate(pts): - if is_same_vtx(pt, p01): continue + if are_same_vtx(pt, p01): continue length01 = (pt - p01).length() length02 = (pt - p02).length() @@ -2844,11 +2844,55 @@ def nextUp(pts=None, pt=None): return oslg.invalid("points (2+)", mth, 1, CN.WRN) for pair in each_cons(pts, 2): - if is_same(pair[0], pt): return pair[-1] + if are_same(pair[0], pt): return pair[-1] return pts[0] +def width(pts=None): + """Returns 'width' of a set of OpenStudio 3D points. + + Args: + pts (openstudio.Point3dVector): + An OpenStudio vector of 3D points. + + Returns: + float: 'Width' along X-axis. + 0.0: If invalid input (see logs). + """ + pts = to_p3Dv(pts) + if len(pts) < 2: return 0 + + xs = [pt.x() for pt in pts] + dx = max(xs) - min(xs) + + return dx + + +def height(pts=None): + """Returns 'width' of a set of OpenStudio 3D points. + + Args: + pts (openstudio.Point3dVector): + An OpenStudio vector of 3D points. + + Returns: + float: 'Height' along Z-axis, or Y-axis if points are flat. + 0.0: If invalid input (see logs). + """ + pts = to_p3Dv(pts) + if len(pts) < 2: return 0 + + zs = [pt.z() for pt in pts] + ys = [pt.y() for pt in pts] + dz = max(zs) - min(zs) + dy = max(ys) - min(ys) + + if abs(dz) > CN.TOL: return dz + + return dy + + def facets(spaces=[], boundary="all", type="all", sides=[]) -> list: """Returns an array of OpenStudio space surfaces or subsurfaces that match criteria, e.g. exterior, north-east facing walls in hotel "lobby". Note diff --git a/tests/test_osut.py b/tests/test_osut.py index 7bad573..36ad3c4 100644 --- a/tests/test_osut.py +++ b/tests/test_osut.py @@ -784,7 +784,7 @@ def test07_construction_thickness(self): # Same vertex sequence? Should be in reverse order. for i, vtx in enumerate(adj.vertices()): - self.assertTrue(osut.is_same(vtx, s.vertices()[i])) + self.assertTrue(osut.are_same(vtx, s.vertices()[i])) self.assertEqual(adj.surfaceType(), "RoofCeiling") self.assertEqual(s.surfaceType(), "RoofCeiling") @@ -798,7 +798,7 @@ def test07_construction_thickness(self): rvtx.reverse() for i, vtx in enumerate(rvtx): - self.assertTrue(osut.is_same(vtx, s.vertices()[i])) + self.assertTrue(osut.are_same(vtx, s.vertices()[i])) # After the fix. if version >= 350: @@ -2327,7 +2327,7 @@ def test22_model_transformation(self): r = openstudio.Transformation.rotation(origin, axis, radians) a = r.inverse() * vtx - self.assertTrue(osut.is_same(a[1], vtx[1])) + self.assertTrue(osut.are_same(a[1], vtx[1])) self.assertAlmostEqual(a[0].x() - a[1].x(), 0) self.assertAlmostEqual(a[2].x() - a[1].x(), hyp2) self.assertAlmostEqual(a[3].x() - a[2].x(), 0) @@ -2336,14 +2336,70 @@ def test22_model_transformation(self): self.assertAlmostEqual(a[3].y() - a[1].y(), hyp) pts = r * a - self.assertTrue(osut.is_same(pts, vtx)) + self.assertTrue(osut.are_same(pts, vtx)) # ... to be completed later. # def test23_fits_overlaps(self): - # - # def test24_triangulation(self): - # + + def test24_triangulation(self): + o = osut.oslg + self.assertEqual(o.status(), 0) + self.assertEqual(o.reset(DBG), DBG) + self.assertEqual(o.level(), DBG) + self.assertEqual(o.status(), 0) + + holes = openstudio.Point3dVectorVector() + + # Regular polygon, counterclockwise yet not UpperLeftCorner (ULC). + vtx = openstudio.Point3dVector() + vtx.append(openstudio.Point3d(20, 0, 10)) + vtx.append(openstudio.Point3d( 0, 0, 10)) + vtx.append(openstudio.Point3d( 0, 0, 0)) + + # Polygons must be 'aligned', and in a clockwise sequence. + t = openstudio.Transformation.alignFace(vtx) + a_vtx = list(t.inverse() * vtx) + a_vtx.reverse() + results = openstudio.computeTriangulation(a_vtx, holes) + self.assertEqual(len(results), 1) + vtx0 = list(results[0]) + vtx0.reverse() + # for vt0 in vtx0: print(vt0) # == initial triangle, yet flat. + # [20, 10, 0] + # [ 0, 10, 0] + # [ 0, 0, 0] + + vtx.append(openstudio.Point3d(20, 0, 0)) + # vtx << OpenStudio::Point3d.new(20, 0, 0) + # t = OpenStudio::Transformation.alignFace(vtx) + # a_vtx = (t.inverse * vtx).reverse + # results = OpenStudio.computeTriangulation(a_vtx, holes) + # expect(results.size).to eq(2) + # results.each { |result| puts result } + # [ 0, 10, 0] + # [20, 10, 0] + # [20, 0, 0] + # + # [ 0, 0, 0] + # [ 0, 10, 0] + # [20, 0, 0] + t = openstudio.Transformation.alignFace(vtx) + a_vtx = list(t.inverse() * vtx) + a_vtx.reverse() + results = openstudio.computeTriangulation(a_vtx, holes) + self.assertEqual(len(results), 2) + vtx0 = list(results[0]) + vtx1 = list(results[0]) + # for vt0 in vtx0: print(vt0) + # [ 0, 10, 0] + # [20, 10, 0] + # [20, 0, 0] + # for vt1 in vtx1: print(vt1) + # [ 0, 10, 0] + # [20, 10, 0] + # [20, 0, 0] + # def test25_segments_triads_orientation(self): # # def test26_ulc_blc(self): @@ -2351,7 +2407,7 @@ def test22_model_transformation(self): # def test27_polygon_attributes(self): # # def test28_subsurface_insertions(self): - # + # # def test29_surface_width_height(self): # # def test30_wwr_insertions(self): From 0ba39ac3946e1f4e1405dcdec158308fb2a8d979 Mon Sep 17 00:00:00 2001 From: brgix Date: Wed, 9 Jul 2025 17:07:07 -0400 Subject: [PATCH 05/49] Adds (yet untested) line segment functions --- src/osut/osut.py | 215 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 213 insertions(+), 2 deletions(-) diff --git a/src/osut/osut.py b/src/osut/osut.py index 5b9c0fc..44da35b 100644 --- a/src/osut/osut.py +++ b/src/osut/osut.py @@ -2849,7 +2849,7 @@ def nextUp(pts=None, pt=None): return pts[0] -def width(pts=None): +def width(pts=None) -> float: """Returns 'width' of a set of OpenStudio 3D points. Args: @@ -2869,7 +2869,7 @@ def width(pts=None): return dx -def height(pts=None): +def height(pts=None) -> float: """Returns 'width' of a set of OpenStudio 3D points. Args: @@ -2893,6 +2893,217 @@ def height(pts=None): return dy +def midpoint(p1=None, p2=None): + """Returns midpoint coordinates of a line segment. + + Args: + p1 (openstudio.Point3d): + 1st 3D point of a line segment. + p2 (openstudio.Point3d): + 2nd 3D point of a line segment. + + Returns: + openstudio.Point3d: Midpoint. + None: If invalid input (see logs). + + """ + mth = "osut.midpoint" + cl = openstudio.Point3d + + if not isinstance(p1, cl): + return oslg.mismatch("point 1", p1, cl, mth) + if not isinstance(p2, cl): + return oslg.mismatch("point 2", p1, cl, mth) + if are_same(p1, p2): + return oslg.invalid("same points", mth) + + midX = p1.x() + (p2.x() - p1.x())/2 + midY = p1.y() + (p2.y() - p1.y())/2 + midZ = p1.z() + (p2.z() - p1.z())/2 + + return openstudio.Point3d(midX, midY, midZ) + + +def verticalPlane(p1=None, p2=None): + """Returns a vertical 3D plane from 2x 3D points, right-hand rule. Input + points are considered last 2 (of 3) points forming the plane; the first + point is assumed zenithal. Input points cannot align vertically. + + Args: + p1 (openstudio.Point3d): + 1st 3D point of a line segment. + p2 (openstudio.Point3d): + 2nd 3D point of a line segment. + + Returns: + openstudio.Plane: A vertical 3D plane. + None: If invalid inputs. + + """ + mth = "osut.verticalPlane" + cl = openstudio.Point3d + + if not isinstance(p1, cl): + return oslg.mismatch("point 1", p1, cl, mth) + if not isinstance(p2, cl): + return oslg.mismatch("point 2", p1, cl, mth) + if are_same(p1, p2): + return oslg.invalid("same points", mth) + + if abs(p1.x() - p2.x()) < CN.TOL and abs(p1.y() - p2.y()) < CN.TOL: + return oslg.invalid("vertically aligned points", mth) + + zenith = openstudio.Point3d(p1.x(), p1.y(), (p2 - p1).length()) + points = openstudio.Point3dVector() + points.append(zenith) + points.append(p1) + points.append(p2) + + return openstudio.Plane(points) + + +def getUniques(pts=None, n=0) -> openstudio.Point3dVector: + """Returns unique OpenStudio 3D points from an OpenStudio 3D point vector. + + Args: + pts (openstudio.Point3dVector): + An OpenStudio vector of 3D points. + n (int): + Requested number of unique points (0 returns all). + + Returns: + openstudio.Point3dVector: Unique points (see logs if empty). + + """ + mth = "osut.getUniques" + pts = to_p3Dv(pts) + v = openstudio.Point3dVector() + if not pts: return v + + try: + n = int(n) + except: + return oslg.mismatch("n unique points", n, int, mth, CN.DBG, v) + + for pt in pts: + if not holds(v, pt): v.append(pt) + + if abs(n) > len(v): n = 0 + if n > 0: v = v[0:n] + if n < 0: v = v[n:] + + return v + + +def segments(pts=None) -> openstudio.Point3dVectorVector: + """Returns paired sequential points as (non-zero length) line segments + (similar to tuple pairs). If the set holds only 2x unique points, a single + segment is returned. Otherwise, the returned number of segments equals the + number of unique points. + + Args: + pts (openstudio.Point3dVector): + An OpenStudio vector of 3D points. + + Returns: + openstudio.Point3dVectorVector: 3D point segments (see logs if empty). + + """ + mth = "osut.segments" + vv = openstudio.Point3dVectorVector() + pts = getUniques(pts) + if len(pts) < 2: return vv + + for i1, pt in enumerate(pts): + i2 = i1 + 1 + if i2 == len(pts): i2 = 0 + p2 = pts[i2] + + line = openstudio.Point3dVector() + line.append(p1) + line.append(p2) + vv.append(line) + if len(pts) == 2: break + + return vv + + +def is_segment(pts=None): + """Determines if a set of 3D points if a valid segment. + + Args: + pts (openstudio.Point3dVector): + An OpenStudio vector of 3D points. + + Returns: + bool: Whether set is a valid segment. + False: If invalid input (see logs). + + """ + pts = to_p3Dv(pts) + if len(pts) != 2: return False + if are_same(pts[0], pts[1]): return False + + return True + + +def triads(pts=None, co=False) -> openstudio.Point3dVectorVector: + """Returns points as (non-zero length) 'triads', i.e. 3x sequential points. + If the set holds less than 3x unique points, an empty triad is returned. + Otherwise, the returned number of triads equals the number of unique points. + If non-collinearity is requested, then the number of returned triads equals + the number of non-collinear points. + + Args: + pts (openstudio.Point3dVector): + An OpenStudio vector of 3D points. + + Returns: + openStudio.Point3dVectorVector: 3D point triads (see logs if empty). + + """ + vv = openstudio.Point3dVectorVector() + pts = getUniques(pts) + if len(pts) < 2: return vv + + for i1, pts in enumerate(pts): + i2 = i1 + 1 + if i2 == len(pts): i2 = 0 + i3 = i2 + 1 + if i3 == len(pts): i3 = 0 + p2 = pts[i2] + p3 = pts[i3] + + tri = openstudio.Point3dVector() + tri.append(p1) + tri.append(p2) + tri.append(p3) + vv.append(tri) + + return vv + + +def is_triad(pts=None): + """Determines if a set of 3D points if a valid 'triad'. + + Args: + pts (openstudio.Point3dVector): + An OpenStudio vector of 3D points. + + Returns: + bool: Whether set is a valid 'triad', i.e. trio of sequential 3D points. + False: If invalid input (see logs). + + """ + pts = to_p3Dv(pts) + if len(pts) != 3: return False + if are_same(pts[0], pts[1]): return False + if are_same(pts[0], pts[2]): return False + if are_same(pts[1], pts[2]): return False + + return True + + def facets(spaces=[], boundary="all", type="all", sides=[]) -> list: """Returns an array of OpenStudio space surfaces or subsurfaces that match criteria, e.g. exterior, north-east facing walls in hotel "lobby". Note From 8d8221109c8011e74047db3f821a61d30999291d Mon Sep 17 00:00:00 2001 From: brgix Date: Thu, 10 Jul 2025 15:54:11 -0400 Subject: [PATCH 06/49] Rehauls vestibule, plenum conditional checks (tested) --- src/osut/osut.py | 456 ++++++++++++++++++++--------- tests/test_osut.py | 704 ++++++++++++++++++++++++--------------------- 2 files changed, 693 insertions(+), 467 deletions(-) diff --git a/src/osut/osut.py b/src/osut/osut.py index 44da35b..97981b9 100644 --- a/src/osut/osut.py +++ b/src/osut/osut.py @@ -210,7 +210,7 @@ def uo() -> dict: def each_cons(it, n): - """A proxy for Ruby Enumerable's 'each_cons(n)' method. + """A proxy for Ruby enumerate's 'each_cons(n)' method. Args: it: @@ -222,7 +222,7 @@ def each_cons(it, n): tuple: n-sized sequenced items. """ - # see: docs.ruby-lang.org/en/3.2/Enumerable.html#method-i-each_cons + # see: docs.ruby-lang.org/en/3.2/enumerate.html#method-i-each_cons # # James Wong's Python workaround implementation: # stackoverflow.com/questions/5878403/python-equivalent-to-rubys-each-cons @@ -996,7 +996,7 @@ def defaultConstructionSet(s=None): return None -def are_standardOpaqueLayers(lc=None) -> bool: +def areStandardOpaqueLayers(lc=None) -> bool: """Validates if every material in a layered construction is standard/opaque. Args: @@ -1008,7 +1008,7 @@ def are_standardOpaqueLayers(lc=None) -> bool: False: If invalid inputs (see logs). """ - mth = "osut.are_standardOpaqueLayers" + mth = "osut.areStandardOpaqueLayers" cl = openstudio.model.LayeredConstruction if not isinstance(lc, cl): @@ -1038,7 +1038,7 @@ def thickness(lc=None) -> float: if not isinstance(lc, cl): return oslg.mismatch("lc", lc, cl, mth, CN.DBG, 0.0) - if not are_standardOpaqueLayers(lc): + if not areStandardOpaqueLayers(lc): oslg.log(CN.ERR, "holding non-StandardOpaqueMaterial(s) %s" % mth) return d @@ -1225,44 +1225,49 @@ def insulatingLayer(lc=None) -> dict: return res -def is_spandrel(s=None) -> bool: - """Validates whether opaque surface can be considered as a curtain wall - (or similar technology) spandrel, regardless of construction layers, by - looking up AdditionalProperties or its identifier. +def areSpandrels(set=None) -> bool: + """Validates whether one or more opaque surface(s) can be considered as + curtain wall (or similar technology) spandrels, regardless of construction + layers, by looking up AdditionalProperties or identifiers. Args: - s (openstudio.model.Surface): - An opaque surface. + set (list): + One or more openstudio.model.Surface instances. Returns: - bool: Whether surface can be considered 'spandrel'. + bool: Whether surface(s) can be considered 'spandrels'. False: If invalid input (see logs). """ - mth = "osut.is_spandrel" + mth = "osut.areSpandrels" cl = openstudio.model.Surface - if not isinstance(s, cl): - return oslg.mismatch("surface", s, cl, mth, CN.DBG, False) - - # Prioritize AdditionalProperties route. - if s.additionalProperties().hasFeature("spandrel"): - val = s.additionalProperties().getFeatureAsBoolean("spandrel") + if isinstance(set, cl): + set = [set] + else: + try: + set = list(set) + except: + return oslg.mismatch("set", set, list, mth, CN.DBG, False) - if not val: - return oslg.invalid("spandrel", mth, 1, CN.ERR, False) + for i, s in enumerate(set): + if not isinstance(s, cl): + return oslg.mismatch("surface %d" % i, s, cl, mth, CN.DBG, False) - val = val.get() + if s.additionalProperties().hasFeature("spandrel"): + val = s.additionalProperties().getFeatureAsBoolean("spandrel") - if not isinstance(val, bool): - return invalid("spandrel bool", mth, 1, CN.ERR, False) + if val: + if val.get() is True: continue + else: return False + else: + oslg.invalid("spandrel %d" % i, mth, 1, CN.ERR) - return val + if "spandrel" not in s.nameString().lower(): return False - # Fallback: check for 'spandrel' in surface name. - return "spandrel" in s.nameString().lower() + return True -def is_fenestration(s=None) -> bool: +def isFenestrated(s=None) -> bool: """Validates whether a sub surface is fenestrated. Args: @@ -1274,7 +1279,7 @@ def is_fenestration(s=None) -> bool: False: If invalid input (see logs). """ - mth = "osut.is_fenestration" + mth = "osut.isFenestrated" cl = openstudio.model.SubSurface if not isinstance(s, cl): @@ -1294,7 +1299,7 @@ def is_fenestration(s=None) -> bool: return True -def has_airLoopsHVAC(model=None) -> bool: +def hasAirLoopsHVAC(model=None) -> bool: """Validates if model has zones with HVAC air loops. Args: @@ -1305,7 +1310,7 @@ def has_airLoopsHVAC(model=None) -> bool: bool: Whether model has HVAC air loops. False: If invalid input (see logs). """ - mth = "osut.has_airLoopsHVAC" + mth = "osut.hasAirLoopsHVAC" cl = openstudio.model.Model if not isinstance(model, cl): @@ -1662,7 +1667,7 @@ def maxHeatScheduledSetpoint(zone=None) -> dict: return res -def has_heatingTemperatureSetpoints(model=None): +def hasHeatingTemperatureSetpoints(model=None): """Confirms if model has zones with valid heating temperature setpoints. Args: @@ -1673,7 +1678,7 @@ def has_heatingTemperatureSetpoints(model=None): bool: Whether model holds valid heating temperature setpoints. False: If invalid inputs (see logs). """ - mth = "osut.has_heatingTemperatureSetpoints" + mth = "osut.hasHeatingTemperatureSetpoints" cl = openstudio.model.Model if not isinstance(model, cl): @@ -1844,7 +1849,7 @@ def minCoolScheduledSetpoint(zone=None): return res -def has_coolingTemperatureSetpoints(model=None): +def hasCoolingTemperatureSetpoints(model=None): """Confirms if model has zones with valid cooling temperature setpoints. Args: @@ -1855,7 +1860,7 @@ def has_coolingTemperatureSetpoints(model=None): bool: Whether model holds valid cooling temperature setpoints. False: If invalid inputs (see logs). """ - mth = "osut.has_coolingTemperatureSetpoints" + mth = "osut.hasCoolingTemperatureSetpoints" cl = openstudio.model.Model if not isinstance(model, cl): @@ -1867,15 +1872,15 @@ def has_coolingTemperatureSetpoints(model=None): return False -def is_vestibule(space=None): - """Validates whether space is a vestibule. +def areVestibules(set=None): + """Validates whether one or more spaces can be considered vestibules(s). Args: - space (): - An OpenStudio space. + set (list): + One or more openstudio.model.Space instances. Returns: - bool: Whether space is considered a vestibule. + bool: Whether space(s) can be considered as vestibule(s). False: If invalid input (see logs). """ # INFO: OpenStudio-Standards' "thermal_zone_vestibule" criteria: @@ -1887,74 +1892,75 @@ def is_vestibule(space=None): # standards/Standards.ThermalZone.rb#L1264 # # This (unused) OpenStudio-Standards method likely needs revision; it - # returns "false" if the thermal zone area were less than 200ft2. Not sure - # which edition of 90.1 relies on a 200ft2 threshold (2010?); 90.1 2016 + # returns "False" if thermal zone areas were less than 200ft2. Not sure + # which edition of 90.1 relies on a 200ft2 threshold (2010?) - 90.1 2016 # doesn't. Yet even fixed, the method would nonetheless misidentify as # "vestibule" a small space along an exterior wall, such as a semiheated # storage space. # - # The code below is intended as a simple short-term solution, basically - # relying on AdditionalProperties, or (if missing) a "vestibule" substring - # within a space's spaceType name (or the latter's standardsSpaceType). + # The code below is intended as a simple (short-term?) workaround, relying + # on AdditionalProperties, or (if missing) a "vestibule" substring within a + # space's spaceType name (or the latter's standardsSpaceType). # - # Alternatively, some future method could infer its status as a vestibule - # based on a few basic features (common to all vintages): + # Some future method could infer its status as vestibule based on a few + # basic features (common to all vintages): # - 1x+ outdoor-facing wall(s) holding 1x+ door(s) # - adjacent to 1x+ 'occupied' conditioned space(s) # - ideally, 1x+ door(s) between vestibule and 1x+ such adjacent space(s) # - # An additional method parameter (i.e. std = "necb") could be added to - # ensure supplementary Standard-specific checks, e.g. maximum floor area, - # minimum distance between doors. + # An additional method parameter (e.g. std = "necb") could be added to + # ensure supplementary Standard-specific checks (e.g. maximum floor area, + # minimum distance between doors). # # Finally, an entirely separate method could be developed to first identify # whether "building entrances" (a defined term in 90.1) actually require # vestibules as per specific code requirements. Food for thought. - mth = "osut.is_vestibule" + mth = "osut.areVestibules" cl = openstudio.model.Space - if not isinstance(space, cl): - return oslg.mismatch("space", space, cl, mth, CN.DBG, False) - - id = space.nameString() - m1 = "%s:vestibule" % id - m2 = "%s:boolean" % m1 + if isinstance(set, cl): + set = [set] + elif not isinstance(set, list): + return oslg.mismatch("set", set, list, mth, CN.DBG, False) - if space.additionalProperties().hasFeature("vestibule"): - val = space.additionalProperties().getFeatureAsBoolean("vestibule") + for space in set: + if not isinstance(space, cl): + return oslg.mismatch("space", space, cl, mth, CN.DBG, False) - if val: - val = val.get() + if space.additionalProperties().hasFeature("vestibule"): + val = space.additionalProperties().getFeatureAsBoolean("vestibule") - if isinstance(val, bool): - return val + if val: + if val.get() is True: continue + else: return False else: - return oslg.invalid(m2, mth, 1, CN.ERR, False) - else: - return oslg.invalid(m1, mth, 1, CN.ERR, False) + oslg.invalid("vestibule", mth, 1, CN.ERR) - if space.spaceType(): - type = space.spaceType().get() - if "plenum" in type.nameString().lower(): return False - if "vestibule" in type.nameString().lower(): return True + if space.spaceType(): + type = space.spaceType().get() + if "plenum" in type.nameString().lower(): return False + if "vestibule" in type.nameString().lower(): continue - if type.standardsSpaceType(): - type = type.standardsSpaceType().get().lower() - if "plenum" in type: return False - if "vestibule" in type: return True + if type.standardsSpaceType(): + type = type.standardsSpaceType().get().lower() + if "plenum" in type: return False + if "vestibule" in type: continue - return False + return False + + return True -def is_plenum(space=None): - """Validates whether a space is an indirectly-conditioned plenum. +def arePlenums(set=None): + """Validates whether one or more spaces can be considered + indirectly-conditioned plenum(s). Args: - space (openstudio.model.Space): - An OpenStudio space. + set (list):space (openstudio.model.Space): + One or more openstudio.model.Space instances. Returns: - bool: Whether space is considered a plenum. + bool: Whether space(s) can be considered plenum(s). False: If invalid input (see logs). """ # Largely inspired from NREL's "space_plenum?": @@ -1963,7 +1969,7 @@ def is_plenum(space=None): # 58964222d25783e9da4ae292e375fb0d5c902aa5/lib/openstudio-standards/ # standards/Standards.Space.rb#L1384 # - # Ideally, OSut's "is_plenum" should be in sync with OpenStudio SDK's + # Ideally, OSut's "arePlenums" should be in sync with OpenStudio SDK's # "isPlenum" method, which solely looks for either HVAC air mixer objects: # - AirLoopHVACReturnPlenum # - AirLoopHVACSupplyPlenum @@ -1999,7 +2005,7 @@ def is_plenum(space=None): # isolation) to determine whether an UNOCCUPIED space should have its # envelope insulated ("plenum") or not ("attic"). # - # In contrast to OpenStudio-Standards' "space_plenum?", OSut's "is_plenum" + # In contrast to OpenStudio-Standards' "space_plenum?", OSut's "arePlenums" # strictly returns FALSE if a space is indeed "partofTotalFloorArea". It # also returns FALSE if the space is a vestibule. Otherwise, it needs more # information to determine if such an UNOCCUPIED space is indeed a @@ -2017,46 +2023,57 @@ def is_plenum(space=None): # spaces that are INDIRECTLYCONDITIONED (not necessarily plenums), then the # following combination is likely more reliable and less confusing: # - SDK's partofTotalFloorArea == FALSE - # - OSut's is_unconditioned == FALSE - mth = "osut.is_plenum" + # - OSut's isUnconditioned == FALSE + mth = "osut.arePlenums" cl = openstudio.model.Space - if not isinstance(space, cl): - return oslg.mismatch("space", space, cl, mth, CN.DBG, False) + if isinstance(set, cl): + set = [set] + elif not isinstance(set, list): + return oslg.mismatch("set", set, list, mth, CN.DBG, False) - if space.partofTotalFloorArea(): return False - if is_vestibule(space): return False + for space in set: + if not isinstance(space, cl): + return oslg.mismatch("space", space, cl, mth, CN.DBG, False) - # CASE A: "plenum" spaceType. - if space.spaceType(): - type = space.spaceType().get() + if space.partofTotalFloorArea(): return False + if areVestibules(space): return False - if "plenum" in type.nameString().lower(): - return True + # CASE A: "plenum" spaceType. + if space.spaceType(): + type = space.spaceType().get() + if "plenum" in type.nameString().lower(): continue - if type.standardsSpaceType(): - type = type.standardsSpaceType().get().lower() + if type.standardsSpaceType(): + type = type.standardsSpaceType().get().lower() + if "plenum" in type: continue - if "plenum" in type: return True + # CASE B: "isPlenum" == TRUE if airloops. + if hasAirLoopsHVAC(space.model()): + if space.isPlenum(): continue - # CASE B: "isPlenum" == TRUE if airloops. - if has_airLoopsHVAC(space.model()): return space.isPlenum() + # CASE C: zone holds an 'inactive' thermostat. + zone = space.thermalZone() + heated = hasHeatingTemperatureSetpoints(space.model()) + cooled = hasCoolingTemperatureSetpoints(space.model()) - # CASE C: zone holds an 'inactive' thermostat. - zone = space.thermalZone() - heated = has_heatingTemperatureSetpoints(space.model()) - cooled = has_coolingTemperatureSetpoints(space.model()) + if heated or cooled: + if zone: + zone = zone.get() + heat = maxHeatScheduledSetpoint(zone) + cool = minCoolScheduledSetpoint(zone) - if heated or cooled: - if not zone: return False + # Directly CONDITIONED? + if heat["spt"]: return False + if cool["spt"]: return False - zone = zone.get() - heat = maxHeatScheduledSetpoint(zone) - cool = minCoolScheduledSetpoint(zone) - if heat["spt"] or cool["spt"]: return False # directly CONDITIONED - return heat["dual"] or cool["dual"] # FALSE if both are None + # Inactive thermostat? + if heat["dual"]: continue + if cool["dual"]: continue - return False + return False + + return True def setpoints(space=None): @@ -2113,8 +2130,8 @@ def setpoints(space=None): log(ERR, "Unknown space %s (%s)" % (id, mth)) # 3. Fetch space setpoints (if model indeed holds valid setpoints). - heated = has_heatingTemperatureSetpoints(space.model()) - cooled = has_coolingTemperatureSetpoints(space.model()) + heated = hasHeatingTemperatureSetpoints(space.model()) + cooled = hasCoolingTemperatureSetpoints(space.model()) zone = space.thermalZone() if heated or cooled: @@ -2138,14 +2155,14 @@ def setpoints(space=None): if not res["cooling"]: res["cooling"] = 24.0 # default # 5. Reset if plenum. - if is_plenum(space): + if arePlenums(space): if not res["heating"]: res["heating"] = 21.0 # default if not res["cooling"]: res["cooling"] = 24.0 # default return res -def is_unconditioned(space=None): +def isUnconditioned(space=None): """Validates if a space is UNCONDITIONED. Args: @@ -2155,7 +2172,7 @@ def is_unconditioned(space=None): bool: Whether space is considered UNCONDITIONED. False: If invalid input (see logs). """ - mth = "osut.is_unconditioned" + mth = "osut.isUnconditioned" cl = openstudio.model.Space if not isinstance(space, cl): @@ -2167,7 +2184,7 @@ def is_unconditioned(space=None): return True -def is_refrigerated(space=None): +def isRefrigerated(space=None): """Confirms if a space can be considered as REFRIGERATED. Args: @@ -2178,7 +2195,7 @@ def is_refrigerated(space=None): bool: Whether space is considered REFRIGERATED. False: If invalid inputs (see logs). """ - mth = "osut.is_refrigerated" + mth = "osut.isRefrigerated" cl = openstudio.model.Space tg0 = "refrigerated" @@ -2204,7 +2221,7 @@ def is_refrigerated(space=None): return False -def is_semiheated(space=None): +def isSemiheated(space=None): """Confirms if a space can be considered as SEMIHEATED as per NECB 2020 1.2.1.2. 2): Design heating setpoint < 15°C (and non-REFRIGERATED). @@ -2216,12 +2233,12 @@ def is_semiheated(space=None): bool: Whether space is considered SEMIHEATED. False: If invalid inputs (see logs). """ - mth = "osut.is_semiheated" + mth = "osut.isSemiheated" cl = openstudio.model.Space if not isinstance(space, cl): return oslg.mismatch("space", space, cl, mth, CN.DBG, False) - if is_refrigerated(space): + if isRefrigerated(space): return False stps = setpoints(space) @@ -2531,7 +2548,7 @@ def to_p3Dv(pts=None) -> openstudio.Point3dVector: return v -def are_same(s1=None, s2=None, indexed=True) -> bool: +def areSame(s1=None, s2=None, indexed=True) -> bool: """Returns True if 2 sets of OpenStudio 3D points are nearly equal. Args: @@ -2608,7 +2625,7 @@ def holds(pts=None, p1=None) -> bool: return oslg.mismatch("point", p1, cl, mth, CN.DBG, False) for pt in pts: - if are_same_vtx(p1, pt): return True + if areSame_vtx(p1, pt): return True return False @@ -2649,7 +2666,7 @@ def nearest(pts=None, p01=None): return oslg.mismatch("point", p01, cl, mth) for i, pt in enumerate(pts): - if are_same_vtx(pt, p01): return i + if areSame_vtx(pt, p01): return i for i, pt in enumerate(pts): length01 = (pt - p01).length() @@ -2710,7 +2727,7 @@ def farthest(pts=None, p01=None): return oslg.mismatch("point", p01, cl, mth) for i, pt in enumerate(pts): - if are_same_vtx(pt, p01): continue + if areSame_vtx(pt, p01): continue length01 = (pt - p01).length() length02 = (pt - p02).length() @@ -2844,7 +2861,7 @@ def nextUp(pts=None, pt=None): return oslg.invalid("points (2+)", mth, 1, CN.WRN) for pair in each_cons(pts, 2): - if are_same(pair[0], pt): return pair[-1] + if areSame(pair[0], pt): return pair[-1] return pts[0] @@ -2914,7 +2931,7 @@ def midpoint(p1=None, p2=None): return oslg.mismatch("point 1", p1, cl, mth) if not isinstance(p2, cl): return oslg.mismatch("point 2", p1, cl, mth) - if are_same(p1, p2): + if areSame(p1, p2): return oslg.invalid("same points", mth) midX = p1.x() + (p2.x() - p1.x())/2 @@ -2947,7 +2964,7 @@ def verticalPlane(p1=None, p2=None): return oslg.mismatch("point 1", p1, cl, mth) if not isinstance(p2, cl): return oslg.mismatch("point 2", p1, cl, mth) - if are_same(p1, p2): + if areSame(p1, p2): return oslg.invalid("same points", mth) if abs(p1.x() - p2.x()) < CN.TOL and abs(p1.y() - p2.y()) < CN.TOL: @@ -2962,7 +2979,7 @@ def verticalPlane(p1=None, p2=None): return openstudio.Plane(points) -def getUniques(pts=None, n=0) -> openstudio.Point3dVector: +def uniques(pts=None, n=0) -> openstudio.Point3dVector: """Returns unique OpenStudio 3D points from an OpenStudio 3D point vector. Args: @@ -2975,7 +2992,7 @@ def getUniques(pts=None, n=0) -> openstudio.Point3dVector: openstudio.Point3dVector: Unique points (see logs if empty). """ - mth = "osut.getUniques" + mth = "osut.uniques" pts = to_p3Dv(pts) v = openstudio.Point3dVector() if not pts: return v @@ -3011,7 +3028,7 @@ def segments(pts=None) -> openstudio.Point3dVectorVector: """ mth = "osut.segments" vv = openstudio.Point3dVectorVector() - pts = getUniques(pts) + pts = uniques(pts) if len(pts) < 2: return vv for i1, pt in enumerate(pts): @@ -3028,7 +3045,7 @@ def segments(pts=None) -> openstudio.Point3dVectorVector: return vv -def is_segment(pts=None): +def isSegment(pts=None): """Determines if a set of 3D points if a valid segment. Args: @@ -3042,7 +3059,7 @@ def is_segment(pts=None): """ pts = to_p3Dv(pts) if len(pts) != 2: return False - if are_same(pts[0], pts[1]): return False + if areSame(pts[0], pts[1]): return False return True @@ -3063,7 +3080,7 @@ def triads(pts=None, co=False) -> openstudio.Point3dVectorVector: """ vv = openstudio.Point3dVectorVector() - pts = getUniques(pts) + pts = uniques(pts) if len(pts) < 2: return vv for i1, pts in enumerate(pts): @@ -3083,7 +3100,7 @@ def triads(pts=None, co=False) -> openstudio.Point3dVectorVector: return vv -def is_triad(pts=None): +def isTriad(pts=None): """Determines if a set of 3D points if a valid 'triad'. Args: @@ -3097,13 +3114,178 @@ def is_triad(pts=None): """ pts = to_p3Dv(pts) if len(pts) != 3: return False - if are_same(pts[0], pts[1]): return False - if are_same(pts[0], pts[2]): return False - if are_same(pts[1], pts[2]): return False + if areSame(pts[0], pts[1]): return False + if areSame(pts[0], pts[2]): return False + if areSame(pts[1], pts[2]): return False return True +def isPointAlongSegment(p0=None, sg=[]) -> bool: + """Validates whether a 3D point lies ~along a 3D point segment, i.e. less + than 10mm from any segment. + + Args: + p0 (openstudio.Point3d): + A 3D point. + sg (openstudio.Point3dVector): + A 3D point segment. + + Returns: + bool: Whether a 3D point lies ~along a 3D point segment. + False: If invalid inputs. + + """ + mth = "osut.isPointAlongSegment" + cl1 = openstudio.Point3d + cl2 = openstudio.Point3dVector + + if not isinstance(p0, cl1): + return oslg.mismatch("point", p0, cl1, mth, CN.DBG, False) + if not isSegment(sg): + return oslg.mismatch("segment", sg, cl2, mth, CN.DBG, False) + + if holds(sg, p0): return True + + a = sg[0] + b = sg[-1] + ab = b - a + abn = b - a + abn.normalize() + ap = p0 - a + sp = ap.dot(abn) + if sp < 0: return False + + apd = scalar(abn, sp) + if apd.length() > ab.length() + CN.TOL: return False + + ap0 = a + apd + if round((p0 - ap0).length(), 2) <= CN.TOL: return True + + return False + + +def isPointAlongSegments(p0=None, sgs=[]): + """Validates whether a 3D point lies anywhere ~along a set of 3D point + segments, i.e. less than 10mm from any segment. + + Args: + p0 (openstudio.Point3d): + A 3D point. + sgs (openstudio.Point3dVectorVector): + 3D point segments. + + Returns: + bool: Whether a 3D point lies ~along a set of 3D point segments. + False: If invalid inputs (see logs). + + """ + mth = "osut.isPointAlongSegments" + cl1 = openstudio.Point3d + cl2 = openstudio.Point3dVectorVector + + if not isinstance(sgs, cl2): + sgs = getSegments(sgs) + if not sgs: + return oslg.empty("segments", mth, CN.DBG, False) + if not isinstance(p0, cl1): + return oslg.mismatch("point", p0, cl, mth, CN.DBG, False) + + for sg in sgs: + if isPointAlongSegment(p0, sg): return True + + return False + + +# Returns point of intersection of 2x 3D line segments. + # + # @param s1 [Set 0 && a.dot(a1b2) > 0 +# lxa1b1 = xa1b1.length +# lxa1b2 = xa1b2.length +# +# c1 = lxa1b1.round(4) < lxa1b2.round(4) ? b1 : b2 +# else +# c1 = a.dot(a1b1) > 0 ? b1 : b2 +# end +# +# c1a1 = a1 - c1 +# xc1a1 = a.cross(c1a1) +# d1 = a1 + xc1a1 +# n = a.cross(xc1a1) +# dot = b.dot(n) +# n = n.reverseVector if dot < 0 +# f = c1a1.dot(n) / b.dot(n) +# p0 = c1 + scalar(b, f) +# +# # Intersection can't be 'behind' point. +# return nil if a.dot(p0 - a1) < 0 +# +# # Ensure intersection is sandwiched between endpoints. +# return nil unless pointAlongSegments?(p0, s2) && pointAlongSegments?(p0, s1) +# +# p0 + + def facets(spaces=[], boundary="all", type="all", sides=[]) -> list: """Returns an array of OpenStudio space surfaces or subsurfaces that match criteria, e.g. exterior, north-east facing walls in hotel "lobby". Note diff --git a/tests/test_osut.py b/tests/test_osut.py index 36ad3c4..2a988cc 100644 --- a/tests/test_osut.py +++ b/tests/test_osut.py @@ -784,7 +784,7 @@ def test07_construction_thickness(self): # Same vertex sequence? Should be in reverse order. for i, vtx in enumerate(adj.vertices()): - self.assertTrue(osut.are_same(vtx, s.vertices()[i])) + self.assertTrue(osut.areSame(vtx, s.vertices()[i])) self.assertEqual(adj.surfaceType(), "RoofCeiling") self.assertEqual(s.surfaceType(), "RoofCeiling") @@ -798,7 +798,7 @@ def test07_construction_thickness(self): rvtx.reverse() for i, vtx in enumerate(rvtx): - self.assertTrue(osut.are_same(vtx, s.vertices()[i])) + self.assertTrue(osut.areSame(vtx, s.vertices()[i])) # After the fix. if version >= 350: @@ -1278,7 +1278,7 @@ def test13_spandrels(self): self.assertEqual(o.level(), DBG) self.assertEqual(o.status(), 0) - version = int("".join(openstudio.openStudioVersion().split("."))) + # version = int("".join(openstudio.openStudioVersion().split("."))) translator = openstudio.osversion.VersionTranslator() path = openstudio.path("./tests/files/osms/out/seb2.osm") @@ -1299,7 +1299,7 @@ def test13_spandrels(self): if not s.outsideBoundaryCondition().lower() == "outdoors": continue if not s.surfaceType().lower() == "wall": continue - self.assertFalse(osut.is_spandrel(s)) + self.assertFalse(osut.areSpandrels(s)) if "smalloffice 1" in s.nameString().lower(): office_walls.append(s) @@ -1314,12 +1314,19 @@ def test13_spandrels(self): tag = "spandrel" for wall in (office_walls + plenum_walls): + # First, failed attempts: + self.assertTrue(wall.additionalProperties().setFeature(tag, "True")) + self.assertTrue(wall.additionalProperties().hasFeature(tag)) + prop = wall.additionalProperties().getFeatureAsBoolean(tag) + self.assertFalse(prop) + + # Successful attempts. self.assertTrue(wall.additionalProperties().setFeature(tag, True)) self.assertTrue(wall.additionalProperties().hasFeature(tag)) prop = wall.additionalProperties().getFeatureAsBoolean(tag) self.assertTrue(prop) self.assertTrue(prop.get()) - self.assertTrue(osut.is_spandrel(wall)) + self.assertTrue(osut.areSpandrels(wall)) self.assertEqual(o.status(), 0) @@ -1747,7 +1754,7 @@ def test18_hvac_airloops(self): self.assertEqual(o.level(), DBG) self.assertEqual(o.status(), 0) - msg = "'model' str? expecting Model (osut.has_airLoopsHVAC)" + msg = "'model' str? expecting Model (osut.hasAirLoopsHVAC)" version = int("".join(openstudio.openStudioVersion().split("."))) translator = openstudio.osversion.VersionTranslator() @@ -1758,9 +1765,9 @@ def test18_hvac_airloops(self): model = model.get() self.assertEqual(o.clean(), DBG) - self.assertTrue(osut.has_airLoopsHVAC(model)) + self.assertTrue(osut.hasAirLoopsHVAC(model)) self.assertEqual(o.status(), 0) - self.assertEqual(osut.has_airLoopsHVAC(""), False) + self.assertEqual(osut.hasAirLoopsHVAC(""), False) self.assertTrue(o.is_debug()) self.assertEqual(len(o.logs()), 1) self.assertEqual(o.logs()[0]["message"], msg) @@ -1775,16 +1782,16 @@ def test18_hvac_airloops(self): model = model.get() self.assertEqual(o.clean(), DBG) - self.assertFalse(osut.has_airLoopsHVAC(model)) + self.assertFalse(osut.hasAirLoopsHVAC(model)) self.assertEqual(o.status(), 0) - self.assertEqual(osut.has_airLoopsHVAC(""), False) + self.assertEqual(osut.hasAirLoopsHVAC(""), False) self.assertTrue(o.is_debug()) self.assertEqual(len(o.logs()), 1) self.assertEqual(o.logs()[0]["message"], msg) self.assertEqual(o.clean(), DBG) del(model) - + def test19_vestibules(self): o = osut.oslg self.assertEqual(o.status(), 0) @@ -1792,7 +1799,6 @@ def test19_vestibules(self): self.assertEqual(o.level(), DBG) self.assertEqual(o.status(), 0) - version = int("".join(openstudio.openStudioVersion().split("."))) translator = openstudio.osversion.VersionTranslator() path = openstudio.path("./tests/files/osms/out/seb2.osm") @@ -1802,19 +1808,57 @@ def test19_vestibules(self): # Tag "Entry way 1" in SEB as a vestibule. tag = "vestibule" + msg = "Invalid 'vestibule' arg #1 (osut.areVestibules)" entry = model.getSpaceByName("Entry way 1") self.assertTrue(entry) entry = entry.get() + sptype = entry.spaceType() + self.assertTrue(sptype) + sptype = sptype.get() + self.assertFalse(sptype.standardsSpaceType()) self.assertFalse(entry.additionalProperties().hasFeature(tag)) - self.assertFalse(osut.is_vestibule(entry)) + self.assertFalse(osut.areVestibules(entry)) + self.assertEqual(o.status(), 0) + + # First, failed attempts: + self.assertTrue(sptype.setStandardsSpaceType("vestibool")) + self.assertFalse(osut.areVestibules(entry)) + self.assertEqual(o.status(), 0) + sptype.resetStandardsSpaceType() + + self.assertTrue(entry.additionalProperties().setFeature(tag, False)) + self.assertTrue(entry.additionalProperties().hasFeature(tag)) + prop = entry.additionalProperties().getFeatureAsBoolean(tag) + self.assertTrue(prop) + self.assertFalse(prop.get()) + self.assertFalse(osut.areVestibules(entry)) + self.assertTrue(entry.additionalProperties().resetFeature(tag)) + self.assertEqual(o.status(), 0) + + self.assertTrue(entry.additionalProperties().setFeature(tag, "True")) + self.assertTrue(entry.additionalProperties().hasFeature(tag)) + prop = entry.additionalProperties().getFeatureAsBoolean(tag) + self.assertFalse(prop) + self.assertFalse(osut.areVestibules(entry)) + self.assertTrue(o.is_error()) + self.assertEqual(len(o.logs()), 1) + self.assertEqual(o.logs()[0]["message"], msg) + self.assertTrue(o.clean(), DBG) + self.assertTrue(entry.additionalProperties().resetFeature(tag)) + + # Successful attempts. + self.assertTrue(sptype.setStandardsSpaceType("vestibule")) + self.assertTrue(osut.areVestibules(entry)) self.assertEqual(o.status(), 0) + sptype.resetStandardsSpaceType() self.assertTrue(entry.additionalProperties().setFeature(tag, True)) self.assertTrue(entry.additionalProperties().hasFeature(tag)) prop = entry.additionalProperties().getFeatureAsBoolean(tag) self.assertTrue(prop) self.assertTrue(prop.get()) - self.assertTrue(osut.is_vestibule(entry)) + self.assertTrue(osut.areVestibules(entry)) + self.assertTrue(entry.additionalProperties().resetFeature(tag)) self.assertEqual(o.status(), 0) del(model) @@ -1828,23 +1872,23 @@ def test20_setpoints_plenums_attics(self): cl1 = openstudio.model.Space cl2 = openstudio.model.Model - mt1 = "(osut.is_plenum)" - mt2 = "(osut.has_heatingTemperatureSetpoints)" + mt1 = "(osut.arePlenums)" + mt2 = "(osut.hasHeatingTemperatureSetpoints)" mt3 = "(osut.setpoints)" - ms1 = "'space' NoneType? expecting %s %s" % (cl1.__name__, mt1) + ms1 = "'set' NoneType? expecting list %s" % mt1 ms2 = "'model' NoneType? expecting %s %s" % (cl2.__name__, mt2) ms3 = "'space' Nonetype? expecting %s %s" % (cl1.__name__, mt3) # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # # Stress tests. self.assertEqual(o.clean(), DBG) - self.assertFalse(osut.is_plenum(None)) + self.assertFalse(osut.arePlenums(None)) self.assertTrue(o.is_debug()) self.assertEqual(len(o.logs()), 1) self.assertEqual(o.logs()[0]["message"], ms1) self.assertEqual(o.clean(), DBG) - self.assertFalse(osut.has_heatingTemperatureSetpoints(None)) + self.assertFalse(osut.hasHeatingTemperatureSetpoints(None)) self.assertTrue(o.is_debug()) self.assertTrue(len(o.logs()), 1) self.assertTrue(o.logs()[0]["message"], ms2) @@ -1884,8 +1928,8 @@ def test20_setpoints_plenums_attics(self): self.assertTrue(heat["dual"]) self.assertTrue(cool["dual"]) - self.assertFalse(osut.is_plenum(space)) - self.assertFalse(osut.is_unconditioned(space)) + self.assertFalse(osut.arePlenums(space)) + self.assertFalse(osut.isUnconditioned(space)) self.assertAlmostEqual(spts["heating"], 22.11, places=2) self.assertAlmostEqual(spts["cooling"], 22.78, places=2) self.assertEqual(o.status(), 0) @@ -1905,8 +1949,8 @@ def test20_setpoints_plenums_attics(self): # "Plenum" spaceType triggers an INDIRECTLYCONDITIONED status; returns # defaulted setpoint temperatures. self.assertFalse(plenum.partofTotalFloorArea()) - self.assertTrue(osut.is_plenum(plenum)) - self.assertFalse(osut.is_unconditioned(plenum)) + self.assertTrue(osut.arePlenums(plenum)) + self.assertFalse(osut.isUnconditioned(plenum)) self.assertAlmostEqual(stps["heating"], 21.00, places=2) self.assertAlmostEqual(stps["cooling"], 24.00, places=2) self.assertEqual(o.status(), 0) @@ -1917,8 +1961,8 @@ def test20_setpoints_plenums_attics(self): val = "Open area 1" self.assertTrue(plenum.additionalProperties().setFeature(key, val)) stps = osut.setpoints(plenum) - self.assertTrue(osut.is_plenum(plenum)) - self.assertFalse(osut.is_unconditioned(plenum)) + self.assertTrue(osut.arePlenums(plenum)) + self.assertFalse(osut.isUnconditioned(plenum)) self.assertAlmostEqual(stps["heating"], 22.11, places=2) self.assertAlmostEqual(stps["cooling"], 22.78, places=2) self.assertEqual(o.status(), 0) @@ -1928,8 +1972,8 @@ def test20_setpoints_plenums_attics(self): key = "space_conditioning_category" val = "Unconditioned" self.assertTrue(plenum.additionalProperties().setFeature(key, val)) - self.assertTrue(osut.is_plenum(plenum)) - self.assertTrue(osut.is_unconditioned(plenum)) + self.assertTrue(osut.arePlenums(plenum)) + self.assertTrue(osut.isUnconditioned(plenum)) self.assertFalse(osut.setpoints(plenum)["heating"]) self.assertFalse(osut.setpoints(plenum)["cooling"]) self.assertEqual(o.status(), 0) @@ -1946,8 +1990,8 @@ def test20_setpoints_plenums_attics(self): # some heating and some cooling, i.e. not strictly REFRIGERATED nor # SEMIHEATED. for space in model.getSpaces(): - self.assertFalse(osut.is_refrigerated(space)) - self.assertFalse(osut.is_semiheated(space)) + self.assertFalse(osut.isRefrigerated(space)) + self.assertFalse(osut.isSemiheated(space)) del(model) @@ -1977,8 +2021,8 @@ def test20_setpoints_plenums_attics(self): self.assertTrue(cool["dual"]) self.assertTrue(space.partofTotalFloorArea()) - self.assertFalse(osut.is_plenum(space)) - self.assertFalse(osut.is_unconditioned(space)) + self.assertFalse(osut.arePlenums(space)) + self.assertFalse(osut.isUnconditioned(space)) self.assertAlmostEqual(stps["heating"], 21.11, places=2) self.assertAlmostEqual(stps["cooling"], 23.89, places=2) @@ -1993,8 +2037,8 @@ def test20_setpoints_plenums_attics(self): self.assertFalse(cool["spt"]) self.assertFalse(heat["dual"]) self.assertFalse(cool["dual"]) - self.assertFalse(osut.is_plenum(attic)) - self.assertTrue(osut.is_unconditioned(attic)) + self.assertFalse(osut.arePlenums(attic)) + self.assertTrue(osut.isUnconditioned(attic)) self.assertFalse(attic.partofTotalFloorArea()) self.assertEqual(o.status(), 0) @@ -2003,8 +2047,8 @@ def test20_setpoints_plenums_attics(self): val = "Core_ZN" self.assertTrue(attic.additionalProperties().setFeature(key, val)) stps = osut.setpoints(attic) - self.assertFalse(osut.is_plenum(attic)) - self.assertFalse(osut.is_unconditioned(attic)) + self.assertFalse(osut.arePlenums(attic)) + self.assertFalse(osut.isUnconditioned(attic)) self.assertAlmostEqual(stps["heating"], 21.11, places=2) self.assertAlmostEqual(stps["cooling"], 23.89, places=2) self.assertEqual(o.status(), 0) @@ -2016,8 +2060,8 @@ def test20_setpoints_plenums_attics(self): msg = "Invalid '%s:%s' (osut.setpoints)" % (key, val) self.assertTrue(attic.additionalProperties().setFeature(key, val)) stps = osut.setpoints(attic) - self.assertFalse(osut.is_plenum(attic)) - self.assertTrue(osut.is_unconditioned(attic)) + self.assertFalse(osut.arePlenums(attic)) + self.assertTrue(osut.isUnconditioned(attic)) self.assertFalse(stps["heating"]) self.assertFalse(stps["cooling"]) self.assertTrue(attic.additionalProperties().hasFeature(key)) @@ -2026,7 +2070,7 @@ def test20_setpoints_plenums_attics(self): self.assertEqual(cnd.get(), val) self.assertTrue(o.is_error()) - # 3x same error, as is_plenum/is_unconditioned call setpoints(attic). + # 3x same error, as arePlenums/isUnconditioned call setpoints(attic). self.assertEqual(len(o.logs()), 3) for l in o.logs(): self.assertEqual(l["message"], msg) @@ -2036,10 +2080,10 @@ def test20_setpoints_plenums_attics(self): val = "Semiheated" self.assertTrue(attic.additionalProperties().setFeature(key, val)) stps = osut.setpoints(attic) - self.assertFalse(osut.is_plenum(attic)) - self.assertFalse(osut.is_unconditioned(attic)) - self.assertTrue(osut.is_semiheated(attic)) - self.assertFalse(osut.is_refrigerated(attic)) + self.assertFalse(osut.arePlenums(attic)) + self.assertFalse(osut.isUnconditioned(attic)) + self.assertTrue(osut.isSemiheated(attic)) + self.assertFalse(osut.isRefrigerated(attic)) self.assertAlmostEqual(stps["heating"], 14.00, places=2) self.assertFalse(stps["cooling"]) self.assertEqual(o.status(), 0) @@ -2053,296 +2097,296 @@ def test20_setpoints_plenums_attics(self): # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # # Consider adding LargeOffice model to test SDK's "isPlenum" ... @todo - def test21_availability_schedules(self): - o = osut.oslg - self.assertEqual(o.status(), 0) - self.assertEqual(o.reset(DBG), DBG) - self.assertEqual(o.level(), DBG) - self.assertEqual(o.status(), 0) - - v = int("".join(openstudio.openStudioVersion().split("."))) - translator = openstudio.osversion.VersionTranslator() - - path = openstudio.path("./tests/files/osms/out/seb2.osm") - model = translator.loadModel(path) - self.assertTrue(model) - model = model.get() - - year = model.yearDescription() - self.assertTrue(year) - year = year.get() - - am01 = openstudio.Time(0, 1) - pm11 = openstudio.Time(0,23) - - jan01 = year.makeDate(openstudio.MonthOfYear("Jan"), 1) - apr30 = year.makeDate(openstudio.MonthOfYear("Apr"), 30) - may01 = year.makeDate(openstudio.MonthOfYear("May"), 1) - oct31 = year.makeDate(openstudio.MonthOfYear("Oct"), 31) - nov01 = year.makeDate(openstudio.MonthOfYear("Nov"), 1) - dec31 = year.makeDate(openstudio.MonthOfYear("Dec"), 31) - self.assertTrue(isinstance(oct31, openstudio.Date)) - - # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # - sch = osut.availabilitySchedule(model) # ON (default) - self.assertTrue(isinstance(sch, openstudio.model.ScheduleRuleset)) - self.assertEqual(sch.nameString(), "ON Availability SchedRuleset") - - limits = sch.scheduleTypeLimits() - self.assertTrue(limits) - limits = limits.get() - name = limits.nameString() - self.assertEqual(name, "HVAC Operation ScheduleTypeLimits") - - default = sch.defaultDaySchedule() - self.assertEqual(default.nameString(), "ON Availability dftDaySched") - self.assertTrue(default.times()) - self.assertTrue(default.values()) - self.assertEqual(len(default.times()), 1) - self.assertEqual(len(default.values()), 1) - self.assertEqual(default.getValue(am01), 1) - self.assertEqual(default.getValue(pm11), 1) - - self.assertTrue(sch.isWinterDesignDayScheduleDefaulted()) - self.assertTrue(sch.isSummerDesignDayScheduleDefaulted()) - self.assertTrue(sch.isHolidayScheduleDefaulted()) - if v >= 330: self.assertTrue(sch.isCustomDay1ScheduleDefaulted()) - if v >= 330: self.assertTrue(sch.isCustomDay2ScheduleDefaulted()) - self.assertEqual(sch.summerDesignDaySchedule(), default) - self.assertEqual(sch.winterDesignDaySchedule(), default) - self.assertEqual(sch.holidaySchedule(), default) - if v >= 330: self.assertEqual(sch.customDay1Schedule(), default) - if v >= 330: self.assertEqual(sch.customDay2Schedule(), default) - self.assertFalse(sch.scheduleRules()) - - # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # - sch = osut.availabilitySchedule(model, "Off") - self.assertTrue(isinstance(sch, openstudio.model.ScheduleRuleset)) - name = sch.nameString() - self.assertEqual(name, "OFF Availability SchedRuleset") - - limits = sch.scheduleTypeLimits() - self.assertTrue(limits) - limits = limits.get() - name = limits.nameString() - self.assertEqual(name, "HVAC Operation ScheduleTypeLimits") - - default = sch.defaultDaySchedule() - self.assertEqual(default.nameString(), "OFF Availability dftDaySched") - self.assertTrue(default.times()) - self.assertTrue(default.values()) - self.assertEqual(len(default.times()), 1) - self.assertEqual(len(default.values()), 1) - self.assertEqual(int(default.getValue(am01)), 0) - self.assertEqual(int(default.getValue(pm11)), 0) - - self.assertTrue(sch.isWinterDesignDayScheduleDefaulted()) - self.assertTrue(sch.isSummerDesignDayScheduleDefaulted()) - self.assertTrue(sch.isHolidayScheduleDefaulted()) - if v >= 330: self.assertTrue(sch.isCustomDay1ScheduleDefaulted()) - if v >= 330: self.assertTrue(sch.isCustomDay2ScheduleDefaulted()) - self.assertEqual(sch.summerDesignDaySchedule(), default) - self.assertEqual(sch.winterDesignDaySchedule(), default) - self.assertEqual(sch.holidaySchedule(), default) - if v >= 330: self.assertEqual(sch.customDay1Schedule(), default) - if v >= 330: self.assertEqual(sch.customDay2Schedule(), default) - self.assertFalse(sch.scheduleRules()) - - # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # - sch = osut.availabilitySchedule(model, "Winter") - self.assertTrue(isinstance(sch, openstudio.model.ScheduleRuleset)) - self.assertEqual(sch.nameString(), "WINTER Availability SchedRuleset") - - limits = sch.scheduleTypeLimits() - self.assertTrue(limits) - limits = limits.get() - name = "HVAC Operation ScheduleTypeLimits" - self.assertEqual(limits.nameString(), name) - - default = sch.defaultDaySchedule() - name = "WINTER Availability dftDaySched" - self.assertEqual(default.nameString(), name) - self.assertTrue(default.times()) - self.assertTrue(default.values()) - self.assertEqual(len(default.times()), 1) - self.assertEqual(len(default.values()), 1) - self.assertEqual(int(default.getValue(am01)), 1) - self.assertEqual(int(default.getValue(pm11)), 1) - - self.assertTrue(sch.isWinterDesignDayScheduleDefaulted()) - self.assertTrue(sch.isSummerDesignDayScheduleDefaulted()) - self.assertTrue(sch.isHolidayScheduleDefaulted()) - if v >= 330: self.assertTrue(sch.isCustomDay1ScheduleDefaulted()) - if v >= 330: self.assertTrue(sch.isCustomDay2ScheduleDefaulted()) - self.assertEqual(sch.summerDesignDaySchedule(), default) - self.assertEqual(sch.winterDesignDaySchedule(), default) - self.assertEqual(sch.holidaySchedule(), default) - if v >= 330: self.assertEqual(sch.customDay1Schedule(), default) - if v >= 330: self.assertEqual(sch.customDay2Schedule(), default) - self.assertEqual(len(sch.scheduleRules()), 1) - - for day_schedule in sch.getDaySchedules(jan01, apr30): - self.assertTrue(day_schedule.times()) - self.assertTrue(day_schedule.values()) - self.assertEqual(len(day_schedule.times()), 1) - self.assertEqual(len(day_schedule.values()), 1) - self.assertEqual(int(day_schedule.getValue(am01)), 1) - self.assertEqual(int(day_schedule.getValue(pm11)), 1) - - for day_schedule in sch.getDaySchedules(may01, oct31): - self.assertTrue(day_schedule.times()) - self.assertTrue(day_schedule.values()) - self.assertEqual(len(day_schedule.times()), 1) - self.assertEqual(len(day_schedule.values()), 1) - self.assertEqual(int(day_schedule.getValue(am01)), 0) - self.assertEqual(int(day_schedule.getValue(pm11)), 0) - - for day_schedule in sch.getDaySchedules(nov01, dec31): - self.assertTrue(day_schedule.times()) - self.assertTrue(day_schedule.values()) - self.assertEqual(len(day_schedule.times()), 1) - self.assertEqual(len(day_schedule.values()), 1) - self.assertEqual(int(day_schedule.getValue(am01)), 1) - self.assertEqual(int(day_schedule.getValue(pm11)), 1) - - # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # - another = osut.availabilitySchedule(model, "Winter") - self.assertEqual(another.nameString(), sch.nameString()) - - # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # - sch = osut.availabilitySchedule(model, "Summer") - self.assertTrue(isinstance(sch, openstudio.model.ScheduleRuleset)) - self.assertEqual(sch.nameString(), "SUMMER Availability SchedRuleset") - - limits = sch.scheduleTypeLimits() - self.assertTrue(limits) - limits = limits.get() - name = "HVAC Operation ScheduleTypeLimits" - self.assertEqual(limits.nameString(), name) - - default = sch.defaultDaySchedule() - name = "SUMMER Availability dftDaySched" - self.assertEqual(default.nameString(), name) - self.assertTrue(default.times()) - self.assertTrue(default.values()) - self.assertEqual(len(default.times()), 1) - self.assertEqual(len(default.values()), 1) - self.assertEqual(int(default.getValue(am01)), 0) - self.assertEqual(int(default.getValue(pm11)), 0) - - self.assertTrue(sch.isWinterDesignDayScheduleDefaulted()) - self.assertTrue(sch.isSummerDesignDayScheduleDefaulted()) - self.assertTrue(sch.isHolidayScheduleDefaulted()) - if v >= 330: self.assertTrue(sch.isCustomDay1ScheduleDefaulted()) - if v >= 330: self.assertTrue(sch.isCustomDay2ScheduleDefaulted()) - self.assertEqual(sch.summerDesignDaySchedule(), default) - self.assertEqual(sch.winterDesignDaySchedule(), default) - self.assertEqual(sch.holidaySchedule(), default) - if v >= 330: self.assertEqual(sch.customDay1Schedule(), default) - if v >= 330: self.assertEqual(sch.customDay2Schedule(), default) - self.assertEqual(len(sch.scheduleRules()), 1) - - for day_schedule in sch.getDaySchedules(jan01, apr30): - self.assertTrue(day_schedule.times()) - self.assertTrue(day_schedule.values()) - self.assertEqual(len(day_schedule.times()), 1) - self.assertEqual(len(day_schedule.values()), 1) - self.assertEqual(int(day_schedule.getValue(am01)), 0) - self.assertEqual(int(day_schedule.getValue(pm11)), 0) - - for day_schedule in sch.getDaySchedules(may01, oct31): - self.assertTrue(day_schedule.times()) - self.assertTrue(day_schedule.values()) - self.assertEqual(len(day_schedule.times()), 1) - self.assertEqual(len(day_schedule.values()), 1) - self.assertEqual(int(day_schedule.getValue(am01)), 1) - self.assertEqual(int(day_schedule.getValue(pm11)), 1) - - for day_schedule in sch.getDaySchedules(nov01, dec31): - self.assertTrue(day_schedule.times()) - self.assertTrue(day_schedule.values()) - self.assertEqual(len(day_schedule.times()), 1) - self.assertEqual(len(day_schedule.values()), 1) - self.assertEqual(int(day_schedule.getValue(am01)), 0) - self.assertEqual(int(day_schedule.getValue(pm11)), 0) - - del(model) - - def test22_model_transformation(self): - o = osut.oslg - self.assertEqual(o.status(), 0) - self.assertEqual(o.reset(DBG), DBG) - self.assertEqual(o.level(), DBG) - self.assertEqual(o.status(), 0) - translator = openstudio.osversion.VersionTranslator() - - # Successful test. - path = openstudio.path("./tests/files/osms/out/seb2.osm") - model = translator.loadModel(path) - self.assertTrue(model) - model = model.get() - - for space in model.getSpaces(): - tr = osut.transforms(space) - self.assertTrue(isinstance(tr, dict)) - self.assertTrue("t" in tr) - self.assertTrue("r" in tr) - self.assertTrue(isinstance(tr["t"], openstudio.Transformation)) - self.assertAlmostEqual(tr["r"], 0, places=2) - - # Invalid input test. - self.assertEqual(o.status(), 0) - m1 = "'group' NoneType? expecting PlanarSurfaceGroup (osut.transforms)" - tr = osut.transforms(None) - self.assertTrue(isinstance(tr, dict)) - self.assertTrue("t" in tr) - self.assertTrue("r" in tr) - self.assertFalse(tr["t"]) - self.assertFalse(tr["r"]) - self.assertTrue(o.is_debug()) - self.assertEqual(len(o.logs()), 1) - self.assertEqual(o.logs()[0]["message"], m1) - self.assertEqual(o.clean(), DBG) - - # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # - # Realignment of flat surfaces. - vtx = openstudio.Point3dVector() - vtx.append(openstudio.Point3d( 1, 4, 0)) - vtx.append(openstudio.Point3d( 2, 2, 0)) - vtx.append(openstudio.Point3d( 6, 4, 0)) - vtx.append(openstudio.Point3d( 5, 6, 0)) - - origin = vtx[1] - hyp = (origin - vtx[0]).length() - hyp2 = (origin - vtx[2]).length() - right = openstudio.Point3d(origin.x()+10, origin.y(), origin.z() ) - zenith = openstudio.Point3d(origin.x(), origin.y(), origin.z()+10) - seg = vtx[2] - origin - axis = zenith - origin - droite = right - origin - radians = openstudio.getAngle(droite, seg) - degrees = openstudio.radToDeg(radians) - self.assertAlmostEqual(degrees, 26.565, places=3) - - r = openstudio.Transformation.rotation(origin, axis, radians) - a = r.inverse() * vtx - - self.assertTrue(osut.are_same(a[1], vtx[1])) - self.assertAlmostEqual(a[0].x() - a[1].x(), 0) - self.assertAlmostEqual(a[2].x() - a[1].x(), hyp2) - self.assertAlmostEqual(a[3].x() - a[2].x(), 0) - self.assertAlmostEqual(a[0].y() - a[1].y(), hyp) - self.assertAlmostEqual(a[2].y() - a[1].y(), 0) - self.assertAlmostEqual(a[3].y() - a[1].y(), hyp) - - pts = r * a - self.assertTrue(osut.are_same(pts, vtx)) - - # ... to be completed later. + # def test21_availability_schedules(self): + # o = osut.oslg + # self.assertEqual(o.status(), 0) + # self.assertEqual(o.reset(DBG), DBG) + # self.assertEqual(o.level(), DBG) + # self.assertEqual(o.status(), 0) + # + # v = int("".join(openstudio.openStudioVersion().split("."))) + # translator = openstudio.osversion.VersionTranslator() + # + # path = openstudio.path("./tests/files/osms/out/seb2.osm") + # model = translator.loadModel(path) + # self.assertTrue(model) + # model = model.get() + # + # year = model.yearDescription() + # self.assertTrue(year) + # year = year.get() + # + # am01 = openstudio.Time(0, 1) + # pm11 = openstudio.Time(0,23) + # + # jan01 = year.makeDate(openstudio.MonthOfYear("Jan"), 1) + # apr30 = year.makeDate(openstudio.MonthOfYear("Apr"), 30) + # may01 = year.makeDate(openstudio.MonthOfYear("May"), 1) + # oct31 = year.makeDate(openstudio.MonthOfYear("Oct"), 31) + # nov01 = year.makeDate(openstudio.MonthOfYear("Nov"), 1) + # dec31 = year.makeDate(openstudio.MonthOfYear("Dec"), 31) + # self.assertTrue(isinstance(oct31, openstudio.Date)) + # + # # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + # sch = osut.availabilitySchedule(model) # ON (default) + # self.assertTrue(isinstance(sch, openstudio.model.ScheduleRuleset)) + # self.assertEqual(sch.nameString(), "ON Availability SchedRuleset") + # + # limits = sch.scheduleTypeLimits() + # self.assertTrue(limits) + # limits = limits.get() + # name = limits.nameString() + # self.assertEqual(name, "HVAC Operation ScheduleTypeLimits") + # + # default = sch.defaultDaySchedule() + # self.assertEqual(default.nameString(), "ON Availability dftDaySched") + # self.assertTrue(default.times()) + # self.assertTrue(default.values()) + # self.assertEqual(len(default.times()), 1) + # self.assertEqual(len(default.values()), 1) + # self.assertEqual(default.getValue(am01), 1) + # self.assertEqual(default.getValue(pm11), 1) + # + # self.assertTrue(sch.isWinterDesignDayScheduleDefaulted()) + # self.assertTrue(sch.isSummerDesignDayScheduleDefaulted()) + # self.assertTrue(sch.isHolidayScheduleDefaulted()) + # if v >= 330: self.assertTrue(sch.isCustomDay1ScheduleDefaulted()) + # if v >= 330: self.assertTrue(sch.isCustomDay2ScheduleDefaulted()) + # self.assertEqual(sch.summerDesignDaySchedule(), default) + # self.assertEqual(sch.winterDesignDaySchedule(), default) + # self.assertEqual(sch.holidaySchedule(), default) + # if v >= 330: self.assertEqual(sch.customDay1Schedule(), default) + # if v >= 330: self.assertEqual(sch.customDay2Schedule(), default) + # self.assertFalse(sch.scheduleRules()) + # + # # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + # sch = osut.availabilitySchedule(model, "Off") + # self.assertTrue(isinstance(sch, openstudio.model.ScheduleRuleset)) + # name = sch.nameString() + # self.assertEqual(name, "OFF Availability SchedRuleset") + # + # limits = sch.scheduleTypeLimits() + # self.assertTrue(limits) + # limits = limits.get() + # name = limits.nameString() + # self.assertEqual(name, "HVAC Operation ScheduleTypeLimits") + # + # default = sch.defaultDaySchedule() + # self.assertEqual(default.nameString(), "OFF Availability dftDaySched") + # self.assertTrue(default.times()) + # self.assertTrue(default.values()) + # self.assertEqual(len(default.times()), 1) + # self.assertEqual(len(default.values()), 1) + # self.assertEqual(int(default.getValue(am01)), 0) + # self.assertEqual(int(default.getValue(pm11)), 0) + # + # self.assertTrue(sch.isWinterDesignDayScheduleDefaulted()) + # self.assertTrue(sch.isSummerDesignDayScheduleDefaulted()) + # self.assertTrue(sch.isHolidayScheduleDefaulted()) + # if v >= 330: self.assertTrue(sch.isCustomDay1ScheduleDefaulted()) + # if v >= 330: self.assertTrue(sch.isCustomDay2ScheduleDefaulted()) + # self.assertEqual(sch.summerDesignDaySchedule(), default) + # self.assertEqual(sch.winterDesignDaySchedule(), default) + # self.assertEqual(sch.holidaySchedule(), default) + # if v >= 330: self.assertEqual(sch.customDay1Schedule(), default) + # if v >= 330: self.assertEqual(sch.customDay2Schedule(), default) + # self.assertFalse(sch.scheduleRules()) + # + # # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + # sch = osut.availabilitySchedule(model, "Winter") + # self.assertTrue(isinstance(sch, openstudio.model.ScheduleRuleset)) + # self.assertEqual(sch.nameString(), "WINTER Availability SchedRuleset") + # + # limits = sch.scheduleTypeLimits() + # self.assertTrue(limits) + # limits = limits.get() + # name = "HVAC Operation ScheduleTypeLimits" + # self.assertEqual(limits.nameString(), name) + # + # default = sch.defaultDaySchedule() + # name = "WINTER Availability dftDaySched" + # self.assertEqual(default.nameString(), name) + # self.assertTrue(default.times()) + # self.assertTrue(default.values()) + # self.assertEqual(len(default.times()), 1) + # self.assertEqual(len(default.values()), 1) + # self.assertEqual(int(default.getValue(am01)), 1) + # self.assertEqual(int(default.getValue(pm11)), 1) + # + # self.assertTrue(sch.isWinterDesignDayScheduleDefaulted()) + # self.assertTrue(sch.isSummerDesignDayScheduleDefaulted()) + # self.assertTrue(sch.isHolidayScheduleDefaulted()) + # if v >= 330: self.assertTrue(sch.isCustomDay1ScheduleDefaulted()) + # if v >= 330: self.assertTrue(sch.isCustomDay2ScheduleDefaulted()) + # self.assertEqual(sch.summerDesignDaySchedule(), default) + # self.assertEqual(sch.winterDesignDaySchedule(), default) + # self.assertEqual(sch.holidaySchedule(), default) + # if v >= 330: self.assertEqual(sch.customDay1Schedule(), default) + # if v >= 330: self.assertEqual(sch.customDay2Schedule(), default) + # self.assertEqual(len(sch.scheduleRules()), 1) + # + # for day_schedule in sch.getDaySchedules(jan01, apr30): + # self.assertTrue(day_schedule.times()) + # self.assertTrue(day_schedule.values()) + # self.assertEqual(len(day_schedule.times()), 1) + # self.assertEqual(len(day_schedule.values()), 1) + # self.assertEqual(int(day_schedule.getValue(am01)), 1) + # self.assertEqual(int(day_schedule.getValue(pm11)), 1) + # + # for day_schedule in sch.getDaySchedules(may01, oct31): + # self.assertTrue(day_schedule.times()) + # self.assertTrue(day_schedule.values()) + # self.assertEqual(len(day_schedule.times()), 1) + # self.assertEqual(len(day_schedule.values()), 1) + # self.assertEqual(int(day_schedule.getValue(am01)), 0) + # self.assertEqual(int(day_schedule.getValue(pm11)), 0) + # + # for day_schedule in sch.getDaySchedules(nov01, dec31): + # self.assertTrue(day_schedule.times()) + # self.assertTrue(day_schedule.values()) + # self.assertEqual(len(day_schedule.times()), 1) + # self.assertEqual(len(day_schedule.values()), 1) + # self.assertEqual(int(day_schedule.getValue(am01)), 1) + # self.assertEqual(int(day_schedule.getValue(pm11)), 1) + # + # # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + # another = osut.availabilitySchedule(model, "Winter") + # self.assertEqual(another.nameString(), sch.nameString()) + # + # # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + # sch = osut.availabilitySchedule(model, "Summer") + # self.assertTrue(isinstance(sch, openstudio.model.ScheduleRuleset)) + # self.assertEqual(sch.nameString(), "SUMMER Availability SchedRuleset") + # + # limits = sch.scheduleTypeLimits() + # self.assertTrue(limits) + # limits = limits.get() + # name = "HVAC Operation ScheduleTypeLimits" + # self.assertEqual(limits.nameString(), name) + # + # default = sch.defaultDaySchedule() + # name = "SUMMER Availability dftDaySched" + # self.assertEqual(default.nameString(), name) + # self.assertTrue(default.times()) + # self.assertTrue(default.values()) + # self.assertEqual(len(default.times()), 1) + # self.assertEqual(len(default.values()), 1) + # self.assertEqual(int(default.getValue(am01)), 0) + # self.assertEqual(int(default.getValue(pm11)), 0) + # + # self.assertTrue(sch.isWinterDesignDayScheduleDefaulted()) + # self.assertTrue(sch.isSummerDesignDayScheduleDefaulted()) + # self.assertTrue(sch.isHolidayScheduleDefaulted()) + # if v >= 330: self.assertTrue(sch.isCustomDay1ScheduleDefaulted()) + # if v >= 330: self.assertTrue(sch.isCustomDay2ScheduleDefaulted()) + # self.assertEqual(sch.summerDesignDaySchedule(), default) + # self.assertEqual(sch.winterDesignDaySchedule(), default) + # self.assertEqual(sch.holidaySchedule(), default) + # if v >= 330: self.assertEqual(sch.customDay1Schedule(), default) + # if v >= 330: self.assertEqual(sch.customDay2Schedule(), default) + # self.assertEqual(len(sch.scheduleRules()), 1) + # + # for day_schedule in sch.getDaySchedules(jan01, apr30): + # self.assertTrue(day_schedule.times()) + # self.assertTrue(day_schedule.values()) + # self.assertEqual(len(day_schedule.times()), 1) + # self.assertEqual(len(day_schedule.values()), 1) + # self.assertEqual(int(day_schedule.getValue(am01)), 0) + # self.assertEqual(int(day_schedule.getValue(pm11)), 0) + # + # for day_schedule in sch.getDaySchedules(may01, oct31): + # self.assertTrue(day_schedule.times()) + # self.assertTrue(day_schedule.values()) + # self.assertEqual(len(day_schedule.times()), 1) + # self.assertEqual(len(day_schedule.values()), 1) + # self.assertEqual(int(day_schedule.getValue(am01)), 1) + # self.assertEqual(int(day_schedule.getValue(pm11)), 1) + # + # for day_schedule in sch.getDaySchedules(nov01, dec31): + # self.assertTrue(day_schedule.times()) + # self.assertTrue(day_schedule.values()) + # self.assertEqual(len(day_schedule.times()), 1) + # self.assertEqual(len(day_schedule.values()), 1) + # self.assertEqual(int(day_schedule.getValue(am01)), 0) + # self.assertEqual(int(day_schedule.getValue(pm11)), 0) + # + # del(model) + # + # def test22_model_transformation(self): + # o = osut.oslg + # self.assertEqual(o.status(), 0) + # self.assertEqual(o.reset(DBG), DBG) + # self.assertEqual(o.level(), DBG) + # self.assertEqual(o.status(), 0) + # translator = openstudio.osversion.VersionTranslator() + # + # # Successful test. + # path = openstudio.path("./tests/files/osms/out/seb2.osm") + # model = translator.loadModel(path) + # self.assertTrue(model) + # model = model.get() + # + # for space in model.getSpaces(): + # tr = osut.transforms(space) + # self.assertTrue(isinstance(tr, dict)) + # self.assertTrue("t" in tr) + # self.assertTrue("r" in tr) + # self.assertTrue(isinstance(tr["t"], openstudio.Transformation)) + # self.assertAlmostEqual(tr["r"], 0, places=2) + # + # # Invalid input test. + # self.assertEqual(o.status(), 0) + # m1 = "'group' NoneType? expecting PlanarSurfaceGroup (osut.transforms)" + # tr = osut.transforms(None) + # self.assertTrue(isinstance(tr, dict)) + # self.assertTrue("t" in tr) + # self.assertTrue("r" in tr) + # self.assertFalse(tr["t"]) + # self.assertFalse(tr["r"]) + # self.assertTrue(o.is_debug()) + # self.assertEqual(len(o.logs()), 1) + # self.assertEqual(o.logs()[0]["message"], m1) + # self.assertEqual(o.clean(), DBG) + # + # # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + # # Realignment of flat surfaces. + # vtx = openstudio.Point3dVector() + # vtx.append(openstudio.Point3d( 1, 4, 0)) + # vtx.append(openstudio.Point3d( 2, 2, 0)) + # vtx.append(openstudio.Point3d( 6, 4, 0)) + # vtx.append(openstudio.Point3d( 5, 6, 0)) + # + # origin = vtx[1] + # hyp = (origin - vtx[0]).length() + # hyp2 = (origin - vtx[2]).length() + # right = openstudio.Point3d(origin.x()+10, origin.y(), origin.z() ) + # zenith = openstudio.Point3d(origin.x(), origin.y(), origin.z()+10) + # seg = vtx[2] - origin + # axis = zenith - origin + # droite = right - origin + # radians = openstudio.getAngle(droite, seg) + # degrees = openstudio.radToDeg(radians) + # self.assertAlmostEqual(degrees, 26.565, places=3) + # + # r = openstudio.Transformation.rotation(origin, axis, radians) + # a = r.inverse() * vtx + # + # self.assertTrue(osut.areSame(a[1], vtx[1])) + # self.assertAlmostEqual(a[0].x() - a[1].x(), 0) + # self.assertAlmostEqual(a[2].x() - a[1].x(), hyp2) + # self.assertAlmostEqual(a[3].x() - a[2].x(), 0) + # self.assertAlmostEqual(a[0].y() - a[1].y(), hyp) + # self.assertAlmostEqual(a[2].y() - a[1].y(), 0) + # self.assertAlmostEqual(a[3].y() - a[1].y(), hyp) + # + # pts = r * a + # self.assertTrue(osut.areSame(pts, vtx)) + # + # # ... to be completed later. # def test23_fits_overlaps(self): - def test24_triangulation(self): + # def test24_triangulation(self): o = osut.oslg self.assertEqual(o.status(), 0) self.assertEqual(o.reset(DBG), DBG) @@ -2407,7 +2451,7 @@ def test24_triangulation(self): # def test27_polygon_attributes(self): # # def test28_subsurface_insertions(self): - # + # # def test29_surface_width_height(self): # # def test30_wwr_insertions(self): From d4e0bd011adb88dffe9868ca6643c59fbad5116c Mon Sep 17 00:00:00 2001 From: brgix Date: Thu, 10 Jul 2025 15:58:10 -0400 Subject: [PATCH 07/49] Reinstates previous tests --- src/osut/osut.py | 91 +------ tests/test_osut.py | 594 ++++++++++++++++++++++----------------------- 2 files changed, 298 insertions(+), 387 deletions(-) diff --git a/src/osut/osut.py b/src/osut/osut.py index 97981b9..8661e30 100644 --- a/src/osut/osut.py +++ b/src/osut/osut.py @@ -2072,7 +2072,7 @@ def arePlenums(set=None): if cool["dual"]: continue return False - + return True @@ -3197,95 +3197,6 @@ def isPointAlongSegments(p0=None, sgs=[]): return False -# Returns point of intersection of 2x 3D line segments. - # - # @param s1 [Set 0 && a.dot(a1b2) > 0 -# lxa1b1 = xa1b1.length -# lxa1b2 = xa1b2.length -# -# c1 = lxa1b1.round(4) < lxa1b2.round(4) ? b1 : b2 -# else -# c1 = a.dot(a1b1) > 0 ? b1 : b2 -# end -# -# c1a1 = a1 - c1 -# xc1a1 = a.cross(c1a1) -# d1 = a1 + xc1a1 -# n = a.cross(xc1a1) -# dot = b.dot(n) -# n = n.reverseVector if dot < 0 -# f = c1a1.dot(n) / b.dot(n) -# p0 = c1 + scalar(b, f) -# -# # Intersection can't be 'behind' point. -# return nil if a.dot(p0 - a1) < 0 -# -# # Ensure intersection is sandwiched between endpoints. -# return nil unless pointAlongSegments?(p0, s2) && pointAlongSegments?(p0, s1) -# -# p0 - - def facets(spaces=[], boundary="all", type="all", sides=[]) -> list: """Returns an array of OpenStudio space surfaces or subsurfaces that match criteria, e.g. exterior, north-east facing walls in hotel "lobby". Note diff --git a/tests/test_osut.py b/tests/test_osut.py index 2a988cc..05a6c20 100644 --- a/tests/test_osut.py +++ b/tests/test_osut.py @@ -1791,7 +1791,7 @@ def test18_hvac_airloops(self): self.assertEqual(o.clean(), DBG) del(model) - + def test19_vestibules(self): o = osut.oslg self.assertEqual(o.status(), 0) @@ -2097,296 +2097,296 @@ def test20_setpoints_plenums_attics(self): # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # # Consider adding LargeOffice model to test SDK's "isPlenum" ... @todo - # def test21_availability_schedules(self): - # o = osut.oslg - # self.assertEqual(o.status(), 0) - # self.assertEqual(o.reset(DBG), DBG) - # self.assertEqual(o.level(), DBG) - # self.assertEqual(o.status(), 0) - # - # v = int("".join(openstudio.openStudioVersion().split("."))) - # translator = openstudio.osversion.VersionTranslator() - # - # path = openstudio.path("./tests/files/osms/out/seb2.osm") - # model = translator.loadModel(path) - # self.assertTrue(model) - # model = model.get() - # - # year = model.yearDescription() - # self.assertTrue(year) - # year = year.get() - # - # am01 = openstudio.Time(0, 1) - # pm11 = openstudio.Time(0,23) - # - # jan01 = year.makeDate(openstudio.MonthOfYear("Jan"), 1) - # apr30 = year.makeDate(openstudio.MonthOfYear("Apr"), 30) - # may01 = year.makeDate(openstudio.MonthOfYear("May"), 1) - # oct31 = year.makeDate(openstudio.MonthOfYear("Oct"), 31) - # nov01 = year.makeDate(openstudio.MonthOfYear("Nov"), 1) - # dec31 = year.makeDate(openstudio.MonthOfYear("Dec"), 31) - # self.assertTrue(isinstance(oct31, openstudio.Date)) - # - # # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # - # sch = osut.availabilitySchedule(model) # ON (default) - # self.assertTrue(isinstance(sch, openstudio.model.ScheduleRuleset)) - # self.assertEqual(sch.nameString(), "ON Availability SchedRuleset") - # - # limits = sch.scheduleTypeLimits() - # self.assertTrue(limits) - # limits = limits.get() - # name = limits.nameString() - # self.assertEqual(name, "HVAC Operation ScheduleTypeLimits") - # - # default = sch.defaultDaySchedule() - # self.assertEqual(default.nameString(), "ON Availability dftDaySched") - # self.assertTrue(default.times()) - # self.assertTrue(default.values()) - # self.assertEqual(len(default.times()), 1) - # self.assertEqual(len(default.values()), 1) - # self.assertEqual(default.getValue(am01), 1) - # self.assertEqual(default.getValue(pm11), 1) - # - # self.assertTrue(sch.isWinterDesignDayScheduleDefaulted()) - # self.assertTrue(sch.isSummerDesignDayScheduleDefaulted()) - # self.assertTrue(sch.isHolidayScheduleDefaulted()) - # if v >= 330: self.assertTrue(sch.isCustomDay1ScheduleDefaulted()) - # if v >= 330: self.assertTrue(sch.isCustomDay2ScheduleDefaulted()) - # self.assertEqual(sch.summerDesignDaySchedule(), default) - # self.assertEqual(sch.winterDesignDaySchedule(), default) - # self.assertEqual(sch.holidaySchedule(), default) - # if v >= 330: self.assertEqual(sch.customDay1Schedule(), default) - # if v >= 330: self.assertEqual(sch.customDay2Schedule(), default) - # self.assertFalse(sch.scheduleRules()) - # - # # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # - # sch = osut.availabilitySchedule(model, "Off") - # self.assertTrue(isinstance(sch, openstudio.model.ScheduleRuleset)) - # name = sch.nameString() - # self.assertEqual(name, "OFF Availability SchedRuleset") - # - # limits = sch.scheduleTypeLimits() - # self.assertTrue(limits) - # limits = limits.get() - # name = limits.nameString() - # self.assertEqual(name, "HVAC Operation ScheduleTypeLimits") - # - # default = sch.defaultDaySchedule() - # self.assertEqual(default.nameString(), "OFF Availability dftDaySched") - # self.assertTrue(default.times()) - # self.assertTrue(default.values()) - # self.assertEqual(len(default.times()), 1) - # self.assertEqual(len(default.values()), 1) - # self.assertEqual(int(default.getValue(am01)), 0) - # self.assertEqual(int(default.getValue(pm11)), 0) - # - # self.assertTrue(sch.isWinterDesignDayScheduleDefaulted()) - # self.assertTrue(sch.isSummerDesignDayScheduleDefaulted()) - # self.assertTrue(sch.isHolidayScheduleDefaulted()) - # if v >= 330: self.assertTrue(sch.isCustomDay1ScheduleDefaulted()) - # if v >= 330: self.assertTrue(sch.isCustomDay2ScheduleDefaulted()) - # self.assertEqual(sch.summerDesignDaySchedule(), default) - # self.assertEqual(sch.winterDesignDaySchedule(), default) - # self.assertEqual(sch.holidaySchedule(), default) - # if v >= 330: self.assertEqual(sch.customDay1Schedule(), default) - # if v >= 330: self.assertEqual(sch.customDay2Schedule(), default) - # self.assertFalse(sch.scheduleRules()) - # - # # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # - # sch = osut.availabilitySchedule(model, "Winter") - # self.assertTrue(isinstance(sch, openstudio.model.ScheduleRuleset)) - # self.assertEqual(sch.nameString(), "WINTER Availability SchedRuleset") - # - # limits = sch.scheduleTypeLimits() - # self.assertTrue(limits) - # limits = limits.get() - # name = "HVAC Operation ScheduleTypeLimits" - # self.assertEqual(limits.nameString(), name) - # - # default = sch.defaultDaySchedule() - # name = "WINTER Availability dftDaySched" - # self.assertEqual(default.nameString(), name) - # self.assertTrue(default.times()) - # self.assertTrue(default.values()) - # self.assertEqual(len(default.times()), 1) - # self.assertEqual(len(default.values()), 1) - # self.assertEqual(int(default.getValue(am01)), 1) - # self.assertEqual(int(default.getValue(pm11)), 1) - # - # self.assertTrue(sch.isWinterDesignDayScheduleDefaulted()) - # self.assertTrue(sch.isSummerDesignDayScheduleDefaulted()) - # self.assertTrue(sch.isHolidayScheduleDefaulted()) - # if v >= 330: self.assertTrue(sch.isCustomDay1ScheduleDefaulted()) - # if v >= 330: self.assertTrue(sch.isCustomDay2ScheduleDefaulted()) - # self.assertEqual(sch.summerDesignDaySchedule(), default) - # self.assertEqual(sch.winterDesignDaySchedule(), default) - # self.assertEqual(sch.holidaySchedule(), default) - # if v >= 330: self.assertEqual(sch.customDay1Schedule(), default) - # if v >= 330: self.assertEqual(sch.customDay2Schedule(), default) - # self.assertEqual(len(sch.scheduleRules()), 1) - # - # for day_schedule in sch.getDaySchedules(jan01, apr30): - # self.assertTrue(day_schedule.times()) - # self.assertTrue(day_schedule.values()) - # self.assertEqual(len(day_schedule.times()), 1) - # self.assertEqual(len(day_schedule.values()), 1) - # self.assertEqual(int(day_schedule.getValue(am01)), 1) - # self.assertEqual(int(day_schedule.getValue(pm11)), 1) - # - # for day_schedule in sch.getDaySchedules(may01, oct31): - # self.assertTrue(day_schedule.times()) - # self.assertTrue(day_schedule.values()) - # self.assertEqual(len(day_schedule.times()), 1) - # self.assertEqual(len(day_schedule.values()), 1) - # self.assertEqual(int(day_schedule.getValue(am01)), 0) - # self.assertEqual(int(day_schedule.getValue(pm11)), 0) - # - # for day_schedule in sch.getDaySchedules(nov01, dec31): - # self.assertTrue(day_schedule.times()) - # self.assertTrue(day_schedule.values()) - # self.assertEqual(len(day_schedule.times()), 1) - # self.assertEqual(len(day_schedule.values()), 1) - # self.assertEqual(int(day_schedule.getValue(am01)), 1) - # self.assertEqual(int(day_schedule.getValue(pm11)), 1) - # - # # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # - # another = osut.availabilitySchedule(model, "Winter") - # self.assertEqual(another.nameString(), sch.nameString()) - # - # # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # - # sch = osut.availabilitySchedule(model, "Summer") - # self.assertTrue(isinstance(sch, openstudio.model.ScheduleRuleset)) - # self.assertEqual(sch.nameString(), "SUMMER Availability SchedRuleset") - # - # limits = sch.scheduleTypeLimits() - # self.assertTrue(limits) - # limits = limits.get() - # name = "HVAC Operation ScheduleTypeLimits" - # self.assertEqual(limits.nameString(), name) - # - # default = sch.defaultDaySchedule() - # name = "SUMMER Availability dftDaySched" - # self.assertEqual(default.nameString(), name) - # self.assertTrue(default.times()) - # self.assertTrue(default.values()) - # self.assertEqual(len(default.times()), 1) - # self.assertEqual(len(default.values()), 1) - # self.assertEqual(int(default.getValue(am01)), 0) - # self.assertEqual(int(default.getValue(pm11)), 0) - # - # self.assertTrue(sch.isWinterDesignDayScheduleDefaulted()) - # self.assertTrue(sch.isSummerDesignDayScheduleDefaulted()) - # self.assertTrue(sch.isHolidayScheduleDefaulted()) - # if v >= 330: self.assertTrue(sch.isCustomDay1ScheduleDefaulted()) - # if v >= 330: self.assertTrue(sch.isCustomDay2ScheduleDefaulted()) - # self.assertEqual(sch.summerDesignDaySchedule(), default) - # self.assertEqual(sch.winterDesignDaySchedule(), default) - # self.assertEqual(sch.holidaySchedule(), default) - # if v >= 330: self.assertEqual(sch.customDay1Schedule(), default) - # if v >= 330: self.assertEqual(sch.customDay2Schedule(), default) - # self.assertEqual(len(sch.scheduleRules()), 1) - # - # for day_schedule in sch.getDaySchedules(jan01, apr30): - # self.assertTrue(day_schedule.times()) - # self.assertTrue(day_schedule.values()) - # self.assertEqual(len(day_schedule.times()), 1) - # self.assertEqual(len(day_schedule.values()), 1) - # self.assertEqual(int(day_schedule.getValue(am01)), 0) - # self.assertEqual(int(day_schedule.getValue(pm11)), 0) - # - # for day_schedule in sch.getDaySchedules(may01, oct31): - # self.assertTrue(day_schedule.times()) - # self.assertTrue(day_schedule.values()) - # self.assertEqual(len(day_schedule.times()), 1) - # self.assertEqual(len(day_schedule.values()), 1) - # self.assertEqual(int(day_schedule.getValue(am01)), 1) - # self.assertEqual(int(day_schedule.getValue(pm11)), 1) - # - # for day_schedule in sch.getDaySchedules(nov01, dec31): - # self.assertTrue(day_schedule.times()) - # self.assertTrue(day_schedule.values()) - # self.assertEqual(len(day_schedule.times()), 1) - # self.assertEqual(len(day_schedule.values()), 1) - # self.assertEqual(int(day_schedule.getValue(am01)), 0) - # self.assertEqual(int(day_schedule.getValue(pm11)), 0) - # - # del(model) - # - # def test22_model_transformation(self): - # o = osut.oslg - # self.assertEqual(o.status(), 0) - # self.assertEqual(o.reset(DBG), DBG) - # self.assertEqual(o.level(), DBG) - # self.assertEqual(o.status(), 0) - # translator = openstudio.osversion.VersionTranslator() - # - # # Successful test. - # path = openstudio.path("./tests/files/osms/out/seb2.osm") - # model = translator.loadModel(path) - # self.assertTrue(model) - # model = model.get() - # - # for space in model.getSpaces(): - # tr = osut.transforms(space) - # self.assertTrue(isinstance(tr, dict)) - # self.assertTrue("t" in tr) - # self.assertTrue("r" in tr) - # self.assertTrue(isinstance(tr["t"], openstudio.Transformation)) - # self.assertAlmostEqual(tr["r"], 0, places=2) - # - # # Invalid input test. - # self.assertEqual(o.status(), 0) - # m1 = "'group' NoneType? expecting PlanarSurfaceGroup (osut.transforms)" - # tr = osut.transforms(None) - # self.assertTrue(isinstance(tr, dict)) - # self.assertTrue("t" in tr) - # self.assertTrue("r" in tr) - # self.assertFalse(tr["t"]) - # self.assertFalse(tr["r"]) - # self.assertTrue(o.is_debug()) - # self.assertEqual(len(o.logs()), 1) - # self.assertEqual(o.logs()[0]["message"], m1) - # self.assertEqual(o.clean(), DBG) - # - # # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # - # # Realignment of flat surfaces. - # vtx = openstudio.Point3dVector() - # vtx.append(openstudio.Point3d( 1, 4, 0)) - # vtx.append(openstudio.Point3d( 2, 2, 0)) - # vtx.append(openstudio.Point3d( 6, 4, 0)) - # vtx.append(openstudio.Point3d( 5, 6, 0)) - # - # origin = vtx[1] - # hyp = (origin - vtx[0]).length() - # hyp2 = (origin - vtx[2]).length() - # right = openstudio.Point3d(origin.x()+10, origin.y(), origin.z() ) - # zenith = openstudio.Point3d(origin.x(), origin.y(), origin.z()+10) - # seg = vtx[2] - origin - # axis = zenith - origin - # droite = right - origin - # radians = openstudio.getAngle(droite, seg) - # degrees = openstudio.radToDeg(radians) - # self.assertAlmostEqual(degrees, 26.565, places=3) - # - # r = openstudio.Transformation.rotation(origin, axis, radians) - # a = r.inverse() * vtx - # - # self.assertTrue(osut.areSame(a[1], vtx[1])) - # self.assertAlmostEqual(a[0].x() - a[1].x(), 0) - # self.assertAlmostEqual(a[2].x() - a[1].x(), hyp2) - # self.assertAlmostEqual(a[3].x() - a[2].x(), 0) - # self.assertAlmostEqual(a[0].y() - a[1].y(), hyp) - # self.assertAlmostEqual(a[2].y() - a[1].y(), 0) - # self.assertAlmostEqual(a[3].y() - a[1].y(), hyp) - # - # pts = r * a - # self.assertTrue(osut.areSame(pts, vtx)) - # - # # ... to be completed later. + def test21_availability_schedules(self): + o = osut.oslg + self.assertEqual(o.status(), 0) + self.assertEqual(o.reset(DBG), DBG) + self.assertEqual(o.level(), DBG) + self.assertEqual(o.status(), 0) + + v = int("".join(openstudio.openStudioVersion().split("."))) + translator = openstudio.osversion.VersionTranslator() + + path = openstudio.path("./tests/files/osms/out/seb2.osm") + model = translator.loadModel(path) + self.assertTrue(model) + model = model.get() + + year = model.yearDescription() + self.assertTrue(year) + year = year.get() + + am01 = openstudio.Time(0, 1) + pm11 = openstudio.Time(0,23) + + jan01 = year.makeDate(openstudio.MonthOfYear("Jan"), 1) + apr30 = year.makeDate(openstudio.MonthOfYear("Apr"), 30) + may01 = year.makeDate(openstudio.MonthOfYear("May"), 1) + oct31 = year.makeDate(openstudio.MonthOfYear("Oct"), 31) + nov01 = year.makeDate(openstudio.MonthOfYear("Nov"), 1) + dec31 = year.makeDate(openstudio.MonthOfYear("Dec"), 31) + self.assertTrue(isinstance(oct31, openstudio.Date)) + + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + sch = osut.availabilitySchedule(model) # ON (default) + self.assertTrue(isinstance(sch, openstudio.model.ScheduleRuleset)) + self.assertEqual(sch.nameString(), "ON Availability SchedRuleset") + + limits = sch.scheduleTypeLimits() + self.assertTrue(limits) + limits = limits.get() + name = limits.nameString() + self.assertEqual(name, "HVAC Operation ScheduleTypeLimits") + + default = sch.defaultDaySchedule() + self.assertEqual(default.nameString(), "ON Availability dftDaySched") + self.assertTrue(default.times()) + self.assertTrue(default.values()) + self.assertEqual(len(default.times()), 1) + self.assertEqual(len(default.values()), 1) + self.assertEqual(default.getValue(am01), 1) + self.assertEqual(default.getValue(pm11), 1) + + self.assertTrue(sch.isWinterDesignDayScheduleDefaulted()) + self.assertTrue(sch.isSummerDesignDayScheduleDefaulted()) + self.assertTrue(sch.isHolidayScheduleDefaulted()) + if v >= 330: self.assertTrue(sch.isCustomDay1ScheduleDefaulted()) + if v >= 330: self.assertTrue(sch.isCustomDay2ScheduleDefaulted()) + self.assertEqual(sch.summerDesignDaySchedule(), default) + self.assertEqual(sch.winterDesignDaySchedule(), default) + self.assertEqual(sch.holidaySchedule(), default) + if v >= 330: self.assertEqual(sch.customDay1Schedule(), default) + if v >= 330: self.assertEqual(sch.customDay2Schedule(), default) + self.assertFalse(sch.scheduleRules()) + + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + sch = osut.availabilitySchedule(model, "Off") + self.assertTrue(isinstance(sch, openstudio.model.ScheduleRuleset)) + name = sch.nameString() + self.assertEqual(name, "OFF Availability SchedRuleset") + + limits = sch.scheduleTypeLimits() + self.assertTrue(limits) + limits = limits.get() + name = limits.nameString() + self.assertEqual(name, "HVAC Operation ScheduleTypeLimits") + + default = sch.defaultDaySchedule() + self.assertEqual(default.nameString(), "OFF Availability dftDaySched") + self.assertTrue(default.times()) + self.assertTrue(default.values()) + self.assertEqual(len(default.times()), 1) + self.assertEqual(len(default.values()), 1) + self.assertEqual(int(default.getValue(am01)), 0) + self.assertEqual(int(default.getValue(pm11)), 0) + + self.assertTrue(sch.isWinterDesignDayScheduleDefaulted()) + self.assertTrue(sch.isSummerDesignDayScheduleDefaulted()) + self.assertTrue(sch.isHolidayScheduleDefaulted()) + if v >= 330: self.assertTrue(sch.isCustomDay1ScheduleDefaulted()) + if v >= 330: self.assertTrue(sch.isCustomDay2ScheduleDefaulted()) + self.assertEqual(sch.summerDesignDaySchedule(), default) + self.assertEqual(sch.winterDesignDaySchedule(), default) + self.assertEqual(sch.holidaySchedule(), default) + if v >= 330: self.assertEqual(sch.customDay1Schedule(), default) + if v >= 330: self.assertEqual(sch.customDay2Schedule(), default) + self.assertFalse(sch.scheduleRules()) + + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + sch = osut.availabilitySchedule(model, "Winter") + self.assertTrue(isinstance(sch, openstudio.model.ScheduleRuleset)) + self.assertEqual(sch.nameString(), "WINTER Availability SchedRuleset") + + limits = sch.scheduleTypeLimits() + self.assertTrue(limits) + limits = limits.get() + name = "HVAC Operation ScheduleTypeLimits" + self.assertEqual(limits.nameString(), name) + + default = sch.defaultDaySchedule() + name = "WINTER Availability dftDaySched" + self.assertEqual(default.nameString(), name) + self.assertTrue(default.times()) + self.assertTrue(default.values()) + self.assertEqual(len(default.times()), 1) + self.assertEqual(len(default.values()), 1) + self.assertEqual(int(default.getValue(am01)), 1) + self.assertEqual(int(default.getValue(pm11)), 1) + + self.assertTrue(sch.isWinterDesignDayScheduleDefaulted()) + self.assertTrue(sch.isSummerDesignDayScheduleDefaulted()) + self.assertTrue(sch.isHolidayScheduleDefaulted()) + if v >= 330: self.assertTrue(sch.isCustomDay1ScheduleDefaulted()) + if v >= 330: self.assertTrue(sch.isCustomDay2ScheduleDefaulted()) + self.assertEqual(sch.summerDesignDaySchedule(), default) + self.assertEqual(sch.winterDesignDaySchedule(), default) + self.assertEqual(sch.holidaySchedule(), default) + if v >= 330: self.assertEqual(sch.customDay1Schedule(), default) + if v >= 330: self.assertEqual(sch.customDay2Schedule(), default) + self.assertEqual(len(sch.scheduleRules()), 1) + + for day_schedule in sch.getDaySchedules(jan01, apr30): + self.assertTrue(day_schedule.times()) + self.assertTrue(day_schedule.values()) + self.assertEqual(len(day_schedule.times()), 1) + self.assertEqual(len(day_schedule.values()), 1) + self.assertEqual(int(day_schedule.getValue(am01)), 1) + self.assertEqual(int(day_schedule.getValue(pm11)), 1) + + for day_schedule in sch.getDaySchedules(may01, oct31): + self.assertTrue(day_schedule.times()) + self.assertTrue(day_schedule.values()) + self.assertEqual(len(day_schedule.times()), 1) + self.assertEqual(len(day_schedule.values()), 1) + self.assertEqual(int(day_schedule.getValue(am01)), 0) + self.assertEqual(int(day_schedule.getValue(pm11)), 0) + + for day_schedule in sch.getDaySchedules(nov01, dec31): + self.assertTrue(day_schedule.times()) + self.assertTrue(day_schedule.values()) + self.assertEqual(len(day_schedule.times()), 1) + self.assertEqual(len(day_schedule.values()), 1) + self.assertEqual(int(day_schedule.getValue(am01)), 1) + self.assertEqual(int(day_schedule.getValue(pm11)), 1) + + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + another = osut.availabilitySchedule(model, "Winter") + self.assertEqual(another.nameString(), sch.nameString()) + + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + sch = osut.availabilitySchedule(model, "Summer") + self.assertTrue(isinstance(sch, openstudio.model.ScheduleRuleset)) + self.assertEqual(sch.nameString(), "SUMMER Availability SchedRuleset") + + limits = sch.scheduleTypeLimits() + self.assertTrue(limits) + limits = limits.get() + name = "HVAC Operation ScheduleTypeLimits" + self.assertEqual(limits.nameString(), name) + + default = sch.defaultDaySchedule() + name = "SUMMER Availability dftDaySched" + self.assertEqual(default.nameString(), name) + self.assertTrue(default.times()) + self.assertTrue(default.values()) + self.assertEqual(len(default.times()), 1) + self.assertEqual(len(default.values()), 1) + self.assertEqual(int(default.getValue(am01)), 0) + self.assertEqual(int(default.getValue(pm11)), 0) + + self.assertTrue(sch.isWinterDesignDayScheduleDefaulted()) + self.assertTrue(sch.isSummerDesignDayScheduleDefaulted()) + self.assertTrue(sch.isHolidayScheduleDefaulted()) + if v >= 330: self.assertTrue(sch.isCustomDay1ScheduleDefaulted()) + if v >= 330: self.assertTrue(sch.isCustomDay2ScheduleDefaulted()) + self.assertEqual(sch.summerDesignDaySchedule(), default) + self.assertEqual(sch.winterDesignDaySchedule(), default) + self.assertEqual(sch.holidaySchedule(), default) + if v >= 330: self.assertEqual(sch.customDay1Schedule(), default) + if v >= 330: self.assertEqual(sch.customDay2Schedule(), default) + self.assertEqual(len(sch.scheduleRules()), 1) + + for day_schedule in sch.getDaySchedules(jan01, apr30): + self.assertTrue(day_schedule.times()) + self.assertTrue(day_schedule.values()) + self.assertEqual(len(day_schedule.times()), 1) + self.assertEqual(len(day_schedule.values()), 1) + self.assertEqual(int(day_schedule.getValue(am01)), 0) + self.assertEqual(int(day_schedule.getValue(pm11)), 0) + + for day_schedule in sch.getDaySchedules(may01, oct31): + self.assertTrue(day_schedule.times()) + self.assertTrue(day_schedule.values()) + self.assertEqual(len(day_schedule.times()), 1) + self.assertEqual(len(day_schedule.values()), 1) + self.assertEqual(int(day_schedule.getValue(am01)), 1) + self.assertEqual(int(day_schedule.getValue(pm11)), 1) + + for day_schedule in sch.getDaySchedules(nov01, dec31): + self.assertTrue(day_schedule.times()) + self.assertTrue(day_schedule.values()) + self.assertEqual(len(day_schedule.times()), 1) + self.assertEqual(len(day_schedule.values()), 1) + self.assertEqual(int(day_schedule.getValue(am01)), 0) + self.assertEqual(int(day_schedule.getValue(pm11)), 0) + + del(model) + + def test22_model_transformation(self): + o = osut.oslg + self.assertEqual(o.status(), 0) + self.assertEqual(o.reset(DBG), DBG) + self.assertEqual(o.level(), DBG) + self.assertEqual(o.status(), 0) + translator = openstudio.osversion.VersionTranslator() + + # Successful test. + path = openstudio.path("./tests/files/osms/out/seb2.osm") + model = translator.loadModel(path) + self.assertTrue(model) + model = model.get() + + for space in model.getSpaces(): + tr = osut.transforms(space) + self.assertTrue(isinstance(tr, dict)) + self.assertTrue("t" in tr) + self.assertTrue("r" in tr) + self.assertTrue(isinstance(tr["t"], openstudio.Transformation)) + self.assertAlmostEqual(tr["r"], 0, places=2) + + # Invalid input test. + self.assertEqual(o.status(), 0) + m1 = "'group' NoneType? expecting PlanarSurfaceGroup (osut.transforms)" + tr = osut.transforms(None) + self.assertTrue(isinstance(tr, dict)) + self.assertTrue("t" in tr) + self.assertTrue("r" in tr) + self.assertFalse(tr["t"]) + self.assertFalse(tr["r"]) + self.assertTrue(o.is_debug()) + self.assertEqual(len(o.logs()), 1) + self.assertEqual(o.logs()[0]["message"], m1) + self.assertEqual(o.clean(), DBG) + + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + # Realignment of flat surfaces. + vtx = openstudio.Point3dVector() + vtx.append(openstudio.Point3d( 1, 4, 0)) + vtx.append(openstudio.Point3d( 2, 2, 0)) + vtx.append(openstudio.Point3d( 6, 4, 0)) + vtx.append(openstudio.Point3d( 5, 6, 0)) + + origin = vtx[1] + hyp = (origin - vtx[0]).length() + hyp2 = (origin - vtx[2]).length() + right = openstudio.Point3d(origin.x()+10, origin.y(), origin.z() ) + zenith = openstudio.Point3d(origin.x(), origin.y(), origin.z()+10) + seg = vtx[2] - origin + axis = zenith - origin + droite = right - origin + radians = openstudio.getAngle(droite, seg) + degrees = openstudio.radToDeg(radians) + self.assertAlmostEqual(degrees, 26.565, places=3) + + r = openstudio.Transformation.rotation(origin, axis, radians) + a = r.inverse() * vtx + + self.assertTrue(osut.areSame(a[1], vtx[1])) + self.assertAlmostEqual(a[0].x() - a[1].x(), 0) + self.assertAlmostEqual(a[2].x() - a[1].x(), hyp2) + self.assertAlmostEqual(a[3].x() - a[2].x(), 0) + self.assertAlmostEqual(a[0].y() - a[1].y(), hyp) + self.assertAlmostEqual(a[2].y() - a[1].y(), 0) + self.assertAlmostEqual(a[3].y() - a[1].y(), hyp) + + pts = r * a + self.assertTrue(osut.areSame(pts, vtx)) + + # ... to be completed later. # def test23_fits_overlaps(self): - # def test24_triangulation(self): + def test24_triangulation(self): o = osut.oslg self.assertEqual(o.status(), 0) self.assertEqual(o.reset(DBG), DBG) @@ -2445,23 +2445,23 @@ def test20_setpoints_plenums_attics(self): # [20, 0, 0] # def test25_segments_triads_orientation(self): - # + # def test26_ulc_blc(self): - # + # def test27_polygon_attributes(self): - # + # def test28_subsurface_insertions(self): - # + # def test29_surface_width_height(self): - # + # def test30_wwr_insertions(self): - # + # def test31_convexity(self): - # + # def test32_outdoor_roofs(self): - # + # def test33_leader_line_anchors_inserts(self): - # + # def test34_generated_skylight_wells(self): def test35_facet_retrieval(self): From 394a4e54349cc69b434474cf35560eb764c629ce Mon Sep 17 00:00:00 2001 From: brgix Date: Fri, 11 Jul 2025 09:25:56 -0400 Subject: [PATCH 08/49] Completes (tested) ULC & BLC --- src/osut/osut.py | 252 ++++++++++++++++++++++++++++++++++++++++++++- tests/test_osut.py | 176 ++++++++++++++++++++++++++++++- 2 files changed, 422 insertions(+), 6 deletions(-) diff --git a/src/osut/osut.py b/src/osut/osut.py index 8661e30..0b286c5 100644 --- a/src/osut/osut.py +++ b/src/osut/osut.py @@ -2591,7 +2591,7 @@ def areSame(s1=None, s2=None, indexed=True) -> bool: if indx is None: return False s2 = collections.deque(s2) - s2.rotate(indx) + s2.rotate(-indx) s2 = list(s2) # openstudio.isAlmostEqual3dPt(p1, p2, TOL) # ... from v350 onwards. @@ -2666,7 +2666,7 @@ def nearest(pts=None, p01=None): return oslg.mismatch("point", p01, cl, mth) for i, pt in enumerate(pts): - if areSame_vtx(pt, p01): return i + if areSame(pt, p01): return i for i, pt in enumerate(pts): length01 = (pt - p01).length() @@ -2727,7 +2727,7 @@ def farthest(pts=None, p01=None): return oslg.mismatch("point", p01, cl, mth) for i, pt in enumerate(pts): - if areSame_vtx(pt, p01): continue + if areSame(pt, p01): continue length01 = (pt - p01).length() length02 = (pt - p02).length() @@ -2829,7 +2829,7 @@ def shareXYZ(pts=None, axs="z", val=0) -> bool: if abs(pt.y() - val) > CN.TOL: return False elif axs.lower() == "z": for pt in pts: - if abs(pt.x() - val) > CN.TOL: return False + if abs(pt.z() - val) > CN.TOL: return False else: return invalid("axis", mth, 2, CN.DBG, False) @@ -3197,6 +3197,250 @@ def isPointAlongSegments(p0=None, sgs=[]): return False +def lineIntersection(s1=[], s2=[]): + """Returns point of intersection of 2x 3D line segments. + + Args: + s1 (openstudio.Point3dVectorVector): + 1st 3D line segment. + s2 (openstudio.Point3dVectorVector): + 2nd 3D line segment. + + Returns: + openStudio.Point3d: Point of intersection of both lines. + None: If no intersection, or invalid input (see logs). + + """ + s1 = segments(s1) + s2 = segments(s2) + if not s1: return None + if not s2: return None + + s1 = s1[0] + s2 = s2[0] + + # Matching segments? + s2x = list(s2) + s2x.reverse() + if areSame(s1, s2x): return None + if areSame(s1, s2) : return None + + a1 = s1[0] + a2 = s1[1] + b1 = s2[0] + b2 = s2[1] + + # Matching segment endpoints? + if areSame(a1, b1): return a1 + if areSame(a2, b1): return a2 + if areSame(a1, b2): return a1 + if areSame(a2, b2): return a2 + + # Segment endpoint along opposite segment? + if isPointAlongSegments(a1, s2): return a1 + if isPointAlongSegments(a2, s2): return a2 + if isPointAlongSegments(b1, s1): return b1 + if isPointAlongSegments(b2, s1): return b2 + + # Line segments as vectors. Skip if colinear. + a = a2 - a1 + b = b2 - b1 + xab = a.cross(b) + if round(xab.length(), 4) < CN.TOL2: return None + + # Link 1st point to other segment endpoints as vectors. Must be coplanar. + a1b1 = b1 - a1 + a1b2 = b2 - a1 + xa1b1 = a.cross(a1b1) + xa1b2 = a.cross(a1b2) + xa1b1.normalize() + xa1b2.normalize() + xab.normalize() + if round(xab.cross(xa1b1).length(), 4) > CN.TOL2: return None + if round(xab.cross(xa1b2).length(), 4) > CN.TOL2: return None + + # Reset. + xa1b1 = a.cross(a1b1) + xa1b2 = a.cross(a1b2) + + # Both segment endpoints can't be 'behind' point. + if a.dot(a1b1) < 0 and a.dot(a1b2) < 0: return None + + # Both in 'front' of point? Pick farthest from 'a'. + if a.dot(a1b1) > 0 and a.dot(a1b2) > 0: + lxa1b1 = xa1b1.length() + lxa1b2 = xa1b2.length() + + c1 = b1 if round(lxa1b1, 4) < round(lxa1b2, 4) else b2 + else: + c1 = b1 if a.dot(a1b1) > 0 else b2 + + c1a1 = a1 - c1 + xc1a1 = a.cross(c1a1) + d1 = a1 + xc1a1 + n = a.cross(xc1a1) + dot = b.dot(n) + if dot < 0: n = n.reverseVector() + f = c1a1.dot(n) / b.dot(n) + p0 = c1 + scalar(b, f) + + # Intersection can't be 'behind' point. + if a.dot(p0 - a1) < 0: return None + + # Ensure intersection is sandwiched between endpoints. + if not isPointAlongSegments(p0, s2): return None + if not isPointAlongSegments(p0, s1): return None + + return p0 + + +def doesLineIntersect(l=[], s=[]): + """Validates whether a 3D line segment intersects 3D segments, e.g. polygon. + + Args: + l (openstudio.Point3dVector): + A 3D line segment. + s (openstudio.Point3dVector): + 3D segments. + + Returns: + bool: Whether a 3D line intersects 3D segments. + False: If invalid input (see logs). + + """ + l = segments(l) + s = segments(s) + if not l: return None + if not s: return None + + l = l[0] + + for segment in s: + if lineIntersection(l, segment): return True + + return False + + +def isClockwise(pts=None): + """Validates whether OpenStudio 3D points are listed clockwise, assuming + points have been pre-'aligned' - not just flattened along XY (i.e. Z = 0). + + Args: + pts: + Pre-aligned 3D points. + + Returns: + bool: Whether sequence is clockwise. + False: If invalid input (see logs). + + """ + mth = "osut.isClockwise" + pts = to_p3Dv(pts) + + if len(pts) < 3: + return oslg.invalid("3+ points", mth, 1, CN.DBG, False) + if not shareXYZ(pts, "z"): + return oslg.invalid("flat points", mth, 1, CN.DBG, False) + + n = openstudio.getOutwardNormal(pts) + + if not n: + return invalid("polygon", mth, 1, CN.DBG, False) + elif n.get().z() > 0: + return False + + return True + + +def ulc(pts=None): + """Returns OpenStudio 3D points (min 3x) conforming to an UpperLeftCorner + (ULC) convention. Points Z-axis values must be ~= 0. Points are returned + counterclockwise. + + Args: + pts: + Pre-aligned 3D points. + + Returns: + openstudio.Point3dVector: ULC points (see logs if empty). + """ + mth = "osut.ulc" + v = openstudio.Point3dVector() + pts = list(to_p3Dv(pts)) + + if len(pts) < 3: + return oslg.invalid("points (3+)", mth, 1, CN.DBG, v) + if not shareXYZ(pts, "z"): + return oslg.invalid("points (aligned)", mth, 1, CN.DBG, v) + + # Ensure counterclockwise sequence. + if isClockwise(pts): pts.reverse() + + minX = min([pt.x() for pt in pts]) + i0 = nearest(pts) + p0 = pts[i0] + + pts_x = [pt for pt in pts if round(pt.x(), 2) == round(minX, 2)] + pts_x.reverse() + p1 = pts_x[0] + + for pt in pts_x: + if round((pt - p0).length(), 2) > round((p1 - p0).length(), 2): p1 = pt + + i1 = pts.index(p1) + pts = collections.deque(pts) + pts.rotate(-i1) + + return to_p3Dv(list(pts)) + + +def blc(pts=None): + """Returns OpenStudio 3D points (min 3x) conforming to an BottomLeftCorner + (BLC) convention. Points Z-axis values must be ~= 0. Points are returned + counterclockwise. + + Args: + pts: + Pre-aligned 3D points. + + Returns: + openstudio.Point3dVector: BLC points (see logs if empty). + """ + mth = "osut.blc" + v = openstudio.Point3dVector() + pts = list(to_p3Dv(pts)) + + if len(pts) < 3: + return oslg.invalid("points (3+)", mth, 1, CN.DBG, v) + if not shareXYZ(pts, "z"): + return oslg.invalid("points (aligned)", mth, 1, CN.DBG, v) + + # Ensure counterclockwise sequence. + if isClockwise(pts): pts.reverse() + + minX = min([pt.x() for pt in pts]) + i0 = nearest(pts) + p0 = pts[i0] + + pts_x = [pt for pt in pts if round(pt.x(), 2) == round(minX, 2)] + pts_x.reverse() + p1 = pts_x[0] + + if p0 in pts_x: + pts = collections.deque(pts) + pts.rotate(-i0) + return to_p3Dv(list(pts)) + + for pt in pts_x: + if round((pt - p0).length(), 2) < round((p1 - p0).length(), 2): p1 = pt + + i1 = pts.index(p1) + pts = collections.deque(pts) + pts.rotate(-i1) + + return to_p3Dv(list(pts)) + + def facets(spaces=[], boundary="all", type="all", sides=[]) -> list: """Returns an array of OpenStudio space surfaces or subsurfaces that match criteria, e.g. exterior, north-east facing walls in hotel "lobby". Note diff --git a/tests/test_osut.py b/tests/test_osut.py index 05a6c20..e750e4c 100644 --- a/tests/test_osut.py +++ b/tests/test_osut.py @@ -2446,7 +2446,179 @@ def test24_triangulation(self): # def test25_segments_triads_orientation(self): - # def test26_ulc_blc(self): + def test26_ulc_blc(self): + o = osut.oslg + self.assertEqual(o.status(), 0) + self.assertEqual(o.reset(DBG), DBG) + self.assertEqual(o.level(), DBG) + self.assertEqual(o.status(), 0) + + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + # Regular polygon, counterclockwise yet not UpperLeftCorner (ULC). + vtx = openstudio.Point3dVector() + vtx.append(openstudio.Point3d(20, 0, 10)) + vtx.append(openstudio.Point3d( 0, 0, 10)) + vtx.append(openstudio.Point3d( 0, 0, 0)) + vtx.append(openstudio.Point3d(20, 0, 0)) + + t = openstudio.Transformation.alignFace(vtx) + a_vtx = t.inverse() * vtx + + # 1. Native ULC reordering. + ulc_a_vtx = openstudio.reorderULC(a_vtx) + ulc_vtx = t * ulc_a_vtx + # for vt in ulc_vtx: print(vt) + # [20, 0, 0] + # [20, 0, 10] + # [ 0, 0, 10] + # [ 0, 0, 0] + self.assertAlmostEqual(ulc_vtx[3].x(), 0, places=2) + self.assertAlmostEqual(ulc_vtx[3].y(), 0, places=2) + self.assertAlmostEqual(ulc_vtx[3].z(), 0, places=2) + # ... counterclockwise, yet ULC? + + # 2. OSut ULC reordering. + ulc_a_vtx = osut.ulc(a_vtx) + blc_a_vtx = osut.blc(a_vtx) + ulc_vtx = t * ulc_a_vtx + blc_vtx = t * blc_a_vtx + self.assertAlmostEqual(ulc_vtx[1].x(), 0, places=2) + self.assertAlmostEqual(ulc_vtx[1].y(), 0, places=2) + self.assertAlmostEqual(ulc_vtx[1].z(), 0, places=2) + self.assertAlmostEqual(blc_vtx[0].x(), 0, places=2) + self.assertAlmostEqual(blc_vtx[0].y(), 0, places=2) + self.assertAlmostEqual(blc_vtx[0].z(), 0, places=2) + # for vt in ulc_vtx: print(vt) + # [ 0, 0, 10] + # [ 0, 0, 0] + # [20, 0, 0] + # [20, 0, 10] + # for vt in blc_vtx: print(vt) + # [ 0, 0, 0] + # [20, 0, 0] + # [20, 0, 10] + # [ 0, 0, 10] + + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + # Same, yet (0,0,0) is at index == 0. + vtx = openstudio.Point3dVector() + vtx.append(openstudio.Point3d( 0, 0, 0)) + vtx.append(openstudio.Point3d(20, 0, 0)) + vtx.append(openstudio.Point3d(20, 0, 10)) + vtx.append(openstudio.Point3d( 0, 0, 10)) + + t = openstudio.Transformation.alignFace(vtx) + a_vtx = t.inverse() * vtx + + # 1. Native ULC reordering. + ulc_a_vtx = openstudio.reorderULC(a_vtx) + ulc_vtx = t * ulc_a_vtx + # for vt in ulc_vtx: print(vt) + # [20, 0, 0] + # [20, 0, 10] + # [ 0, 0, 10] + # [ 0, 0, 0] # ... consistent with first case. + + # 2. OSut ULC reordering. + ulc_a_vtx = osut.ulc(a_vtx) + blc_a_vtx = osut.blc(a_vtx) + ulc_vtx = t * ulc_a_vtx + blc_vtx = t * blc_a_vtx + self.assertAlmostEqual(ulc_vtx[1].x(), 0, places=2) + self.assertAlmostEqual(ulc_vtx[1].y(), 0, places=2) + self.assertAlmostEqual(ulc_vtx[1].z(), 0, places=2) + self.assertAlmostEqual(blc_vtx[0].x(), 0, places=2) + self.assertAlmostEqual(blc_vtx[0].y(), 0, places=2) + self.assertAlmostEqual(blc_vtx[0].z(), 0, places=2) + # for vt in ulc_vtx: print(vt) + # [ 0, 0, 10] + # [ 0, 0, 0] + # [20, 0, 0] + # [20, 0, 10] + # for vt in blc_vtx: print(vt) + # [ 0, 0, 0] + # [20, 0, 0] + # [20, 0, 10] + # [ 0, 0, 10] + + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + # Irregular polygon, no point at 0,0,0. + vtx = openstudio.Point3dVector() + vtx.append(openstudio.Point3d(18, 0, 10)) + vtx.append(openstudio.Point3d( 2, 0, 10)) + vtx.append(openstudio.Point3d( 0, 0, 6)) + vtx.append(openstudio.Point3d( 0, 0, 4)) + vtx.append(openstudio.Point3d( 2, 0, 0)) + vtx.append(openstudio.Point3d(18, 0, 0)) + vtx.append(openstudio.Point3d(20, 0, 4)) + vtx.append(openstudio.Point3d(20, 0, 6)) + + t = openstudio.Transformation.alignFace(vtx) + a_vtx = t.inverse() * vtx + + # 1. Native ULC reordering. + ulc_a_vtx = openstudio.reorderULC(a_vtx) + ulc_vtx = t * ulc_a_vtx + # for vt in ulc_vtx: print(vt) + # [18, 0, 0] + # [20, 0, 4] + # [20, 0, 6] + # [18, 0, 10] + # [ 2, 0, 10] + # [ 0, 0, 6] + # [ 0, 0, 4] + # [ 2, 0, 0] ... consistent pattern with previous cases, yet ULC? + + # 2. OSut ULC reordering. + ulc_a_vtx = osut.ulc(a_vtx) + blc_a_vtx = osut.blc(a_vtx) + iN = osut.nearest(ulc_a_vtx) + iF = osut.farthest(ulc_a_vtx) + self.assertEqual(iN, 2) + self.assertEqual(iF, 6) + ulc_vtx = t * ulc_a_vtx + blc_vtx = t * blc_a_vtx + self.assertTrue(osut.areSame(ulc_vtx[2], ulc_vtx[iN])) + self.assertTrue(osut.areSame(blc_vtx[1], ulc_vtx[iN])) + # for vt in ulc_vtx: print(vt) + # [ 0, 0, 6] + # [ 0, 0, 4] + # [ 2, 0, 0] + # [18, 0, 0] + # [20, 0, 4] + # [20, 0, 6] + # [18, 0, 10] + # [ 2, 0, 10] + # for vt in blc_vtx: print(vt) + # [ 0, 0, 4] + # [ 2, 0, 0] + # [18, 0, 0] + # [20, 0, 4] + # [20, 0, 6] + # [18, 0, 10] + # [ 2, 0, 10] + # [ 0, 0, 6] + + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + vtx = openstudio.Point3dVector() + vtx.append(openstudio.Point3d(70, 45, 0)) + vtx.append(openstudio.Point3d( 0, 45, 0)) + vtx.append(openstudio.Point3d( 0, 0, 0)) + vtx.append(openstudio.Point3d(70, 0, 0)) + + ulc_vtx = osut.ulc(vtx) + blc_vtx = osut.blc(vtx) + self.assertEqual(o.status(), 0) + # for vt in ulc_vtx: print(vt) + # [ 0, 45, 0] + # [ 0, 0, 0] + # [70, 0, 0] + # [70, 45, 0] + # for vt in blc_vtx: print(vt) + # [ 0, 0, 0] + # [70, 0, 0] + # [70, 45, 0] + # [ 0, 45, 0] # def test27_polygon_attributes(self): @@ -2461,7 +2633,7 @@ def test24_triangulation(self): # def test32_outdoor_roofs(self): # def test33_leader_line_anchors_inserts(self): - + # def test34_generated_skylight_wells(self): def test35_facet_retrieval(self): From 8266f632f05ab65b78e507cc21efa88b32637690 Mon Sep 17 00:00:00 2001 From: brgix Date: Sat, 12 Jul 2025 07:03:06 -0400 Subject: [PATCH 09/49] Fixes & tests collinearity methods --- src/osut/osut.py | 128 ++++++++++++++++++++++++++++++++++++++++----- tests/test_osut.py | 69 +++++++++++++++++++++++- 2 files changed, 182 insertions(+), 15 deletions(-) diff --git a/src/osut/osut.py b/src/osut/osut.py index 0b286c5..b3ca690 100644 --- a/src/osut/osut.py +++ b/src/osut/osut.py @@ -2625,7 +2625,7 @@ def holds(pts=None, p1=None) -> bool: return oslg.mismatch("point", p1, cl, mth, CN.DBG, False) for pt in pts: - if areSame_vtx(p1, pt): return True + if areSame(p1, pt): return True return False @@ -3045,7 +3045,7 @@ def segments(pts=None) -> openstudio.Point3dVectorVector: return vv -def isSegment(pts=None): +def isSegment(pts=None) -> bool: """Determines if a set of 3D points if a valid segment. Args: @@ -3100,7 +3100,7 @@ def triads(pts=None, co=False) -> openstudio.Point3dVectorVector: return vv -def isTriad(pts=None): +def isTriad(pts=None) -> bool: """Determines if a set of 3D points if a valid 'triad'. Args: @@ -3165,7 +3165,7 @@ def isPointAlongSegment(p0=None, sg=[]) -> bool: return False -def isPointAlongSegments(p0=None, sgs=[]): +def isPointAlongSegments(p0=None, sgs=[]) -> bool: """Validates whether a 3D point lies anywhere ~along a set of 3D point segments, i.e. less than 10mm from any segment. @@ -3294,7 +3294,7 @@ def lineIntersection(s1=[], s2=[]): return p0 -def doesLineIntersect(l=[], s=[]): +def doesLineIntersect(l=[], s=[]) -> bool: """Validates whether a 3D line segment intersects 3D segments, e.g. polygon. Args: @@ -3321,13 +3321,13 @@ def doesLineIntersect(l=[], s=[]): return False -def isClockwise(pts=None): +def isClockwise(pts=None) -> bool: """Validates whether OpenStudio 3D points are listed clockwise, assuming points have been pre-'aligned' - not just flattened along XY (i.e. Z = 0). Args: - pts: - Pre-aligned 3D points. + pts (openstudio.Point3dVector): + An OpenStudio vector of pre-aligned 3D points. Returns: bool: Whether sequence is clockwise. @@ -3352,14 +3352,14 @@ def isClockwise(pts=None): return True -def ulc(pts=None): +def ulc(pts=None) -> openstudio.Point3dVector: """Returns OpenStudio 3D points (min 3x) conforming to an UpperLeftCorner (ULC) convention. Points Z-axis values must be ~= 0. Points are returned counterclockwise. Args: - pts: - Pre-aligned 3D points. + pts (openstudio.Point3dVector): + An OpenStudio vector of pre-aligned 3D points. Returns: openstudio.Point3dVector: ULC points (see logs if empty). @@ -3394,14 +3394,14 @@ def ulc(pts=None): return to_p3Dv(list(pts)) -def blc(pts=None): +def blc(pts=None) -> openstudio.Point3dVector: """Returns OpenStudio 3D points (min 3x) conforming to an BottomLeftCorner (BLC) convention. Points Z-axis values must be ~= 0. Points are returned counterclockwise. Args: - pts: - Pre-aligned 3D points. + pts (openstudio.Point3dVector): + An OpenStudio vector of pre-aligned 3D points. Returns: openstudio.Point3dVector: BLC points (see logs if empty). @@ -3441,6 +3441,106 @@ def blc(pts=None): return to_p3Dv(list(pts)) +def nonCollinears(pts=None, n=0) -> openstudio.Point3dVector: + """Returns sequential non-collinear points in an OpenStudio 3D point vector. + + Args: + pts (openstudio.Point3dVector): + An OpenStudio vector of 3D points. + n (int): + Requested number of non-collinears (0 returns all). + + Returns: + openstudio.Point3dVector: non-collinears (see logs if empty). + + """ + mth = "osut.nonCollinears" + v = openstudio.Point3dVector() + a = [] + pts = uniques(pts) + if len(pts) < 3: return pts + + try: + n = int(n) + except: + oslg.mismatch("n non-collinears", n, int, mth, CN.DBG, v) + + if n > len(pts): + return oslg.invalid("+n non-collinears", mth, 0, CN.ERR, v) + elif n < 0 and abs(n) > len(pts): + return oslg.invalid("-n non-collinears", mth, 0, CN.ERR, v) + + # Evaluate cross product of vectors of 3x sequential points. + for i2, p2 in enumerate(pts): + i1 = i2 - 1 + i3 = i2 + 1 + if i3 == len(pts): i3 = 0 + p1 = pts[i1] + p3 = pts[i3] + + v13 = p3 - p1 + v12 = p2 - p1 + if v12.cross(v13).length() < CN.TOL2: continue + + a.append(p2) + + if pts[0] in a: + if not areSame(a[0], pts[0]): a = a.rotate(1) + + if n > len(a): return to_p3Dv(a) + if n < 0 and abs(n) > len(a): return to_p3Dv(a) + + if n > 0: a = a[0:n] + if n < 0: a = a[n:] + + return to_p3Dv(a) + + +def collinears(pts=None, n=0) -> openstudio.Point3dVector: + """ + Returns sequential collinear points in an OpenStudio 3D point vector. + + Args: + pts (openstudio.Point3dVector): + An OpenStudio vector of pre-aligned 3D points. + n (int): + Requested number of collinears (0 returns all). + + Returns: + openstudio.Point3dVector: collinears (see logs if empty). + + """ + mth = "osut.collinears" + v = openstudio.Point3dVector() + a = [] + pts = uniques(pts) + if len(pts) < 3: return pts + + try: + n = int(n) + except: + oslg.mismatch("n collinears", n, int, mth, CN.DBG, v) + + if n > len(pts): + return oslg.invalid("+n collinears", mth, 0, CN.ERR, v) + elif n < 0 and abs(n) > len(pts): + return oslg.invalid("-n collinears", mth, 0, CN.ERR, v) + + ncolls = nonCollinears(pts) + if not ncolls: return pts + + for pt in pts: + if pt not in ncolls: a.append(pt) + + if n > len(a): return to_p3Dv(a) + if n < 0 and abs(n) > len(a): return to_p3Dv(a) + + if n > 0: a = a[0:n] + if n < 0: a = a[n:] + + return to_p3Dv(a) + + def facets(spaces=[], boundary="all", type="all", sides=[]) -> list: """Returns an array of OpenStudio space surfaces or subsurfaces that match criteria, e.g. exterior, north-east facing walls in hotel "lobby". Note diff --git a/tests/test_osut.py b/tests/test_osut.py index e750e4c..1071633 100644 --- a/tests/test_osut.py +++ b/tests/test_osut.py @@ -2444,7 +2444,74 @@ def test24_triangulation(self): # [20, 10, 0] # [20, 0, 0] - # def test25_segments_triads_orientation(self): + def test25_segments_triads_orientation(self): + o = osut.oslg + self.assertEqual(o.status(), 0) + self.assertEqual(o.reset(DBG), DBG) + self.assertEqual(o.level(), DBG) + self.assertEqual(o.status(), 0) + + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + # Basic OpenStudio intersection methods. + + # Enclosed polygon. + p0 = openstudio.Point3d(-5, -5, -5) + p1 = openstudio.Point3d( 5, 5, -5) + p2 = openstudio.Point3d(15, 15, -5) + p3 = openstudio.Point3d(15, 25, -5) + + # Independent line segment. + p4 = openstudio.Point3d(10,-30, -5) + p5 = openstudio.Point3d(10, 10, -5) + p6 = openstudio.Point3d(10, 40, -5) + + # Independent points. + p7 = openstudio.Point3d(14, 20, -5) + p8 = openstudio.Point3d(-9, -9, -5) + + # Stress tests. + m1 = "Invalid '+n collinears' (osut.collinears)" + m2 = "Invalid '-n collinears' (osut.collinears)" + + collinears = osut.collinears([p0, p1, p3, p8]) + self.assertEqual(len(collinears), 1) + self.assertTrue(osut.areSame(collinears[0], p0)) + + collinears = osut.collinears([p0, p1, p2, p3, p8]) + self.assertEqual(len(collinears), 2) + self.assertTrue(osut.areSame(collinears[0], p0)) + self.assertTrue(osut.areSame(collinears[1], p1)) + + collinears = osut.collinears([p0, p1, p2, p3, p8], 3) + self.assertEqual(len(collinears), 2) + self.assertTrue(osut.areSame(collinears[0], p0)) + self.assertTrue(osut.areSame(collinears[1], p1)) + + collinears = osut.collinears([p0, p1, p2, p3, p8], 1) + self.assertEqual(len(collinears), 1) + self.assertTrue(osut.areSame(collinears[0], p0)) + + collinears = osut.collinears([p0, p1, p2, p3, p8], -1) + self.assertEqual(len(collinears), 1) + self.assertTrue(osut.areSame(collinears[0], p1)) + + collinears = osut.collinears([p0, p1, p2, p3, p8], -2) + self.assertEqual(len(collinears), 2) + self.assertTrue(osut.areSame(collinears[0], p0)) + self.assertTrue(osut.areSame(collinears[1], p1)) + + collinears = osut.collinears([p0, p1, p2, p3, p8], 6) + self.assertTrue(o.is_error()) + self.assertEqual(len(o.logs()), 1) + self.assertEqual(o.logs()[0]["message"], m1) + self.assertTrue(o.clean(), DBG) + + collinears = osut.collinears([p0, p1, p2, p3, p8], -6) + self.assertTrue(o.is_error()) + self.assertEqual(len(o.logs()), 1) + self.assertEqual(o.logs()[0]["message"], m2) + + self.assertTrue(o.clean(), DBG) def test26_ulc_blc(self): o = osut.oslg From aae73071e223ba0507b79408c1bc99c4a0ec7089 Mon Sep 17 00:00:00 2001 From: brgix Date: Sat, 12 Jul 2025 10:48:48 -0400 Subject: [PATCH 10/49] Fixes & tests point-line intersection methods --- src/osut/osut.py | 31 ++++++++++++++++++++----------- tests/test_osut.py | 45 +++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 61 insertions(+), 15 deletions(-) diff --git a/src/osut/osut.py b/src/osut/osut.py index b3ca690..16cdbcf 100644 --- a/src/osut/osut.py +++ b/src/osut/osut.py @@ -3031,7 +3031,7 @@ def segments(pts=None) -> openstudio.Point3dVectorVector: pts = uniques(pts) if len(pts) < 2: return vv - for i1, pt in enumerate(pts): + for i1, p1 in enumerate(pts): i2 = i1 + 1 if i2 == len(pts): i2 = 0 p2 = pts[i2] @@ -3185,7 +3185,7 @@ def isPointAlongSegments(p0=None, sgs=[]) -> bool: cl2 = openstudio.Point3dVectorVector if not isinstance(sgs, cl2): - sgs = getSegments(sgs) + sgs = segments(sgs) if not sgs: return oslg.empty("segments", mth, CN.DBG, False) if not isinstance(p0, cl1): @@ -3211,8 +3211,8 @@ def lineIntersection(s1=[], s2=[]): None: If no intersection, or invalid input (see logs). """ - s1 = segments(s1) - s2 = segments(s2) + s1 = segments(s1) + s2 = segments(s2) if not s1: return None if not s2: return None @@ -3237,12 +3237,12 @@ def lineIntersection(s1=[], s2=[]): if areSame(a2, b2): return a2 # Segment endpoint along opposite segment? - if isPointAlongSegments(a1, s2): return a1 - if isPointAlongSegments(a2, s2): return a2 - if isPointAlongSegments(b1, s1): return b1 - if isPointAlongSegments(b2, s1): return b2 + if isPointAlongSegment(a1, s2): return a1 + if isPointAlongSegment(a2, s2): return a2 + if isPointAlongSegment(b1, s1): return b1 + if isPointAlongSegment(b2, s1): return b2 - # Line segments as vectors. Skip if colinear. + # Line segments as vectors. Skip if collinear or parallel. a = a2 - a1 b = b2 - b1 xab = a.cross(b) @@ -3263,6 +3263,14 @@ 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 + # Both segment endpoints can't be 'behind' point. if a.dot(a1b1) < 0 and a.dot(a1b2) < 0: return None @@ -3281,6 +3289,7 @@ def lineIntersection(s1=[], s2=[]): n = a.cross(xc1a1) dot = b.dot(n) if dot < 0: n = n.reverseVector() + if abs(b.dot(n)) < CN.TOL: return None f = c1a1.dot(n) / b.dot(n) p0 = c1 + scalar(b, f) @@ -3288,8 +3297,8 @@ def lineIntersection(s1=[], s2=[]): if a.dot(p0 - a1) < 0: return None # Ensure intersection is sandwiched between endpoints. - if not isPointAlongSegments(p0, s2): return None - if not isPointAlongSegments(p0, s1): return None + if not isPointAlongSegment(p0, s2): return None + if not isPointAlongSegment(p0, s1): return None return p0 diff --git a/tests/test_osut.py b/tests/test_osut.py index 1071633..5367655 100644 --- a/tests/test_osut.py +++ b/tests/test_osut.py @@ -2451,9 +2451,6 @@ def test25_segments_triads_orientation(self): self.assertEqual(o.level(), DBG) self.assertEqual(o.status(), 0) - # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # - # Basic OpenStudio intersection methods. - # Enclosed polygon. p0 = openstudio.Point3d(-5, -5, -5) p1 = openstudio.Point3d( 5, 5, -5) @@ -2510,9 +2507,49 @@ def test25_segments_triads_orientation(self): self.assertTrue(o.is_error()) self.assertEqual(len(o.logs()), 1) self.assertEqual(o.logs()[0]["message"], m2) - self.assertTrue(o.clean(), DBG) + # CASE a1: 2x end-to-end line segments (returns matching endpoints). + self.assertTrue(osut.doesLineIntersect([p0, p1], [p1, p2])) + pt = osut.lineIntersection([p0, p1], [p1, p2]) + self.assertTrue(osut.areSame(pt, p1)) + # + # # CASE a2: as a1, sequence of line segment endpoints doesn't matter. + self.assertTrue(osut.doesLineIntersect([p1, p0], [p1, p2])) + pt = osut.lineIntersection([p1, p0], [p1, p2]) + self.assertTrue(osut.areSame(pt, p1)) + # + # # CASE b1: 2x right-angle line segments, with 1x matching at corner. + self.assertTrue(osut.doesLineIntersect([p1, p2], [p1, p3])) + pt = osut.lineIntersection([p1, p2], [p2, p3]) + self.assertTrue(osut.areSame(pt, p2)) + # + # # CASE b2: as b1, sequence of segments doesn't matter. + self.assertTrue(osut.doesLineIntersect([p2, p3], [p1, p2])) + pt = osut.lineIntersection([p2, p3], [p1, p2]) + self.assertTrue(osut.areSame(pt, p2)) + + # CASE c: 2x right-angle line segments, yet disconnected. + self.assertFalse(osut.doesLineIntersect([p0, p1], [p2, p3])) + pt = osut.lineIntersection([p0, p1], [p2, p3]) + self.assertFalse(pt) + + # CASE d: 2x connected line segments, acute angle. + self.assertTrue(osut.doesLineIntersect([p0, p2], [p3, p0])) + pt = osut.lineIntersection([p0, p2], [p3, p0]) + self.assertTrue(osut.areSame(pt, p0)) + # + # # CASE e1: 2x disconnected line segments, right angle. + self.assertTrue(osut.doesLineIntersect([p0, p2], [p4, p6])) + pt = osut.lineIntersection([p0, p2], [p4, p6]) + self.assertTrue(osut.areSame(pt, p5)) + # + # # CASE e2: as e1, sequence of line segment endpoints doesn't matter. + self.assertTrue(osut.doesLineIntersect([p0, p2], [p6, p4])) + pt = osut.lineIntersection([p0, p2], [p6, p4]) + self.assertTrue(osut.areSame(pt, p5)) + + def test26_ulc_blc(self): o = osut.oslg self.assertEqual(o.status(), 0) From 5ba1ace0c22725d4392f066784eb776f4b0165e3 Mon Sep 17 00:00:00 2001 From: brgix Date: Sun, 13 Jul 2025 09:26:53 -0400 Subject: [PATCH 11/49] Adds a handful of boolean functions --- src/osut/osut.py | 387 ++++++++++++++++++++++++++++++++++++++++++++- tests/test_osut.py | 89 ++++++++--- 2 files changed, 451 insertions(+), 25 deletions(-) diff --git a/src/osut/osut.py b/src/osut/osut.py index 16cdbcf..c21d3f7 100644 --- a/src/osut/osut.py +++ b/src/osut/osut.py @@ -2598,7 +2598,7 @@ def areSame(s1=None, s2=None, indexed=True) -> bool: for i in range(len(s1)): if abs(s1[i].x() - s2[i].x()) > CN.TOL: return False if abs(s1[i].y() - s2[i].y()) > CN.TOL: return False - if abs(s1[i].z() - s2[i].z()) > CN.TOL: return false + if abs(s1[i].z() - s2[i].z()) > CN.TOL: return False return True @@ -3494,7 +3494,10 @@ def nonCollinears(pts=None, n=0) -> openstudio.Point3dVector: a.append(p2) if pts[0] in a: - if not areSame(a[0], pts[0]): a = a.rotate(1) + if not areSame(a[0], pts[0]): + a = collections.deque(a) + a.rotate(1) + a = list(a) if n > len(a): return to_p3Dv(a) if n < 0 and abs(n) > len(a): return to_p3Dv(a) @@ -3511,7 +3514,7 @@ def collinears(pts=None, n=0) -> openstudio.Point3dVector: Args: pts (openstudio.Point3dVector): - An OpenStudio vector of pre-aligned 3D points. + An OpenStudio vector of 3D points. n (int): Requested number of collinears (0 returns all). @@ -3550,6 +3553,384 @@ def collinears(pts=None, n=0) -> openstudio.Point3dVector: return to_p3Dv(a) +def poly(pts=None, vx=False, uq=False, co=False, tt=False, sq="no") -> openstudio.Point3dVector: + """Returns an OpenStudio 3D point vector as basis for a valid OpenStudio 3D + polygon. In addition to basic OpenStudio polygon tests (e.g. all points + sharing the same 3D plane, non-self-intersecting), the method can + optionally check for convexity, or ensure uniqueness and/or non-collinearity. + Returned vector can also be 'aligned', as well as in UpperLeftCorner (ULC), + BottomLeftCorner (BLC), in clockwise (or counterclockwise) sequences. + + Args: + pts (openstudio.Point3dVector): + An OpenStudio vector of 3D points. + vx (bool): + Whether to check for convexity. + uq (bool): + Whether to ensure uniqueness. + co (bool): + Whether to ensure non-collinearity. + tt (bool, openstudio.Transformation): + Whether to 'align'. + sq ("no", "ulc", "blc", "cw"): + Unaltered, ULC, BLC or clockwise sequence. + + Returns: + openstudio.Point3dVector: 3D points (see logs if empty). + + """ + mth = "osut.poly" + pts = to_p3Dv(pts) + cl = openstudio.Transformation + v = openstudio.Point3dVector() + sqs = ["no", "ulc", "blc", "cw"] + if not isinstance(vx, bool): vx = False + if not isinstance(uq, bool): uq = False + if not isinstance(co, bool): co = False + + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + # Exit if mismatched/invalid arguments. + if not isinstance(tt, bool) and not isinstance(tt, cl): + return oslg.invalid("transformation", mth, 5, CN.DBG, v) + + if sq not in sqs: + return oslg.invalid("sequence", mth, 6, CN.DBG, v) + + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + # Minimum 3 points? + p3 = nonCollinears(pts, 3) + + if len(p3) < 3: + return oslg.empty("polygon (non-collinears < 3)", mth, CN.ERR, v) + + # Coplanar? + pln = openstudio.Plane(p3) + + for pt in pts: + if not pln.pointOnPlane(pt): return oslg.empty("plane", mth, CN.ERR, v) + + t = openstudio.Transformation.alignFace(pts) + at = t.inverse() * pts + at.reverse() + + if isinstance(tt, cl): + att = tt.inverse() * pts + att.reverse() + + if areSame(at, att): + a = att + if isClockwise(a): a = list(ulc(a)) + t = None + else: + if shareXYZ(att, "z"): + t = None + else: + t = openstudio.Transformation.alignFace(att) + + if t: + a = t.inverse() * att + a.reverse() + else: + a = att + else: + a = at + + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + # Ensure uniqueness and/or non-collinearity. Preserve original sequence. + p0 = a[0] + i0 = None + if uq: a = list(uniques(a)) + if co: a = list(nonCollinears(a)) + + i0 = [i for i, pt in enumerate(a) if areSame(pt, p0)] + + if i0: + i0 = i0[0] + a = collections.deque(a) + a.rotate(-i0) + a = list(a) + + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + # Check for convexity (optional). + if vx and len(a) > 3: + zen = openstudio.Point3d(0, 0, 1000) + + for trio in triads(a): + p1 = trio[0] + p2 = trio[1] + p3 = trio[2] + v12 = p2 - p1 + v13 = p3 - p1 + x = (zen - p1).cross(v12) + if round(x.dot(v13), 4) > 0: return v + + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + # Alter sequence (optional). + if sq != "cw": a.reverse() + + if isinstance(tt, cl): + if sq == "ulc": + a = to_p3Dv(t * ulc(a)) if t else to_p3Dv(ulc(a)) + elif sq == "blc": + a = to_p3Dv(t * blc(a)) if t else to_p3Dv(blc(a)) + elif sq == "cw": + a = to_p3Dv(t * a) if t else to_p3Dv(a) + else: + a = to_p3Dv(t * a) if t else to_p3Dv(a) + else: + if sq == "ulc": + a = to_p3Dv(ulc(a)) if tt else to_p3Dv(t * ulc(a)) + elif sq == "blc": + a = to_p3Dv(blc(a)) if tt else to_p3Dv(t * blc(a)) + elif sq == "cw": + a = to_p3Dv(a) if tt else to_p3Dv(t * a) + else: + a = to_p3Dv(a) if tt else to_p3Dv(t * a) + + return a + + +def isPointWithinPolygon(p0=None, s=[], entirely=False) -> bool: + """Validates whether 3D point is within a 3D polygon. If option 'entirely' + is set to True, then the method returns False if point lies along any of + the polygon edges, or is very near any of its vertices. + + Args: + p0 (openstudio.Point3d): + a 3D point. + s (openstudio.Point3dVector): + A 3D polygon. + entirely (bool): + Whether point should be neatly within polygon limits. + + Returns: + bool: Whether 3D point lies within 3D polygon. + False: If invalid inputs (see logs). + + """ + mth = "osut.isPointWithinPolygon" + cl = openstudio.Point3d + + if not isinstance(p0, cl): + return oslg.mismatch("point", p0, cl, mth, CN.DBG, False) + + s = poly(s, False, True, True) + if not s: return oslg.empty("polygon", mth, CN.DBG, False) + + n = OpenStudio.getOutwardNormal(s) + if not n: return oslg.invalid("plane/normal", mth, 2, CN.DBG, False) + + n = n.get() + pl = openstudio.Plane(s[0], n) + if not pl.pointOnPlane(p0): return False + if not isinstance(entireley, bool): entirely = False + + segs = segments(s) + + # Along polygon edges, or near vertices? + if isPointAlongSegments(p0, segs): + return False if entirely else True + + for segment in segs: + # - draw vector from segment midpoint to point + # - scale 1000x (assuming no building surface would be 1km wide) + # - convert vector to an independent line segment + # - loop through polygon segments, tally the number of intersections + # - avoid double-counting polygon vertices as intersections + # - return False if number of intersections is even + mid = midpoint(segment[0], segment[1]) + mpV = scalar(mid - p0, 1000) + p1 = p0 + mpV + ctr = 0 + + # Skip if ~collinear. + if round(mpV.cross(segment[1] - segment[0]).length(), 4) < CN.TOL2: + continue + + for sg in segs: + intersect = lineIntersection([p0, p1], sg) + if not intersect: continue + + # Skip test altogether if one of the polygon vertices. + if holds(s, intersect): + ctr = 0 + break + else: + ctr += 1 + + if ctr == 0: continue + if ctr % 2 == 0: return False # 'even'? + + return True + + +def areParallel(p1=None, p2=None) -> bool: + """Validates whether 2 polygons are parallel, regardless of their direction. + + Args: + p1 (openstudio.Point3dVector): + 1st set of 3D points. + p2 (openstudio.Point3dVector): + 2nd set of 3D points. + + Returns: + bool: Whether 2 polygons are parallel. + False: If invalid inputs. + + """ + p1 = poly(p1, False, True) + p2 = poly(p2, False, True) + if not p1: return False + if not p2: return False + + n1 = OpenStudio.getOutwardNormal(p1) + n2 = OpenStudio.getOutwardNormal(p2) + if not n1: return False + if not n2: return False + + return abs(n1.get().dot(n2.get())) > 0.99 + + +def isRoof(pts=None) -> bool: + """Validates whether a polygon can be considered a valid 'roof' surface, as + per ASHRAE 90.1 & Canadian NECBs, i.e. outward normal within 60° from + vertical. + + Args: + pts (openstudio.Point3dVector): + An OpenStudio vector of pre-aligned 3D points. + + Returns: + bool: If considered a roof surface. + False: If invalid input (see logs). + """ + ray = openstudio.Point3d(0,0,1) - openstudio.Point3d(0,0,0) + dut = math.cos(60 * math.pi / 180) + pts = poly(pts, False, True, True) + if not pts: return False + + dot = ray.dot(openstudio.getOutwardNormal(pts).get()) + if round(dot, 2) <= 0: return False + if round(dot, 2) == 1: return True + + return round(dot, 4) >= round(dut, 4) + + +def facingUp(pts=None) -> bool: + """Validates whether a polygon faces upwards, harmonized with OpenStudio + Utilities' "alignZPrime" function. + + Args: + pts (openstudio.Point3dVector): + An OpenStudio vector of 3D points. + + Returns: + bool: If facing upwards. + False: If invalid inputs (see logs). + + """ + ray = openstudio.Point3d(0,0,1) - openstudio.Point3d(0,0,0) + pts = poly(pts, False, True, True) + if not pts : return False + + return openstudio.getOutwardNormal(pts).get().dot(ray) > 0.99 + + +def facingDown(pts=None) -> bool: + """Validates whether a polygon faces downwards, harmonized with OpenStudio + Utilities' "alignZPrime" function. + + Args: + pts (openstudio.Point3dVector): + An OpenStudio vector of 3D points. + + Returns: + bool: If facing downwards. + False: If invalid inputs (see logs). + + """ + ray = openstudio.Point3d(0,0,-1) - openstudio.Point3d(0,0,0) + pts = poly(pts, False, True, True) + if not pts: return False + + return openstudio.getOutwardNormal(pts).get().dot(ray) > 0.99 + + +def isSloped(pts=None) -> bool: + """Validates whether a surface can be considered 'sloped' (i.e. not ~flat, + as per OpenStudio Utilities' "alignZPrime"). Vertical polygons returns True. + + Args: + pts (openstudio.Point3dVector): + An OpenStudio vector of 3D points. + + Returns: + bool: Whether surface is sloped. + False: If invalid input (see logs). + + """ + pts = poly(pts, False, True, True) + if not pts: return False + if facingUp(pts): return False + if facingDown(pts): return False + + return True + + +def isRectangular(pts=None) -> bool: + """Validates whether an OpenStudio polygon is a rectangle (4x sides + 2x + diagonals of equal length, meeting at midpoints). + + Args: + pts (openstudio.Point3dVector): + An OpenStudio vector of 3D points. + + Returns: + bool: Whether polygon is rectangular. + False: If invalid input (see logs). + + """ + pts = poly(pts, False, False, False) + if not pts: return False + if len(pts) != 4: return False + + m1 = midpoint(pts[0], pts[2]) + m2 = midpoint(pts[1], pts[3]) + if not areSame(m1, m2): return False + + diag1 = pts[2] - pts[0] + diag2 = pts[3] - pts[1] + if abs(diag1.length() - diag2.length()) < CN.TOL: return True + + return False + + +def isSquare(pts=None) -> bool: + """Validates whether an OpenStudio polygon is a square (rectangular, + 4x ~equal sides). + + Args: + pts (openstudio.Point3dVector): + An OpenStudio vector of 3D points. + + Returns: + bool: Whether polygon is a square. + False: If invalid input (see logs). + + """ + d = None + pts = poly(pts, False, False, False) + if not pts: return False + if not isRectangular(pts): return False + + for pt in segments(pts): + l = (pt[1] - pt[0]).length() + if not d: d = l + if round(l, 2) != round(d, 2): return False + + return True + + def facets(spaces=[], boundary="all", type="all", sides=[]) -> list: """Returns an array of OpenStudio space surfaces or subsurfaces that match criteria, e.g. exterior, north-east facing walls in hotel "lobby". Note diff --git a/tests/test_osut.py b/tests/test_osut.py index 5367655..f52f2c4 100644 --- a/tests/test_osut.py +++ b/tests/test_osut.py @@ -2384,7 +2384,68 @@ def test22_model_transformation(self): # ... to be completed later. - # def test23_fits_overlaps(self): + def test23_fits_overlaps(self): + o = osut.oslg + self.assertEqual(o.status(), 0) + self.assertEqual(o.reset(DBG), DBG) + self.assertEqual(o.level(), DBG) + self.assertEqual(o.status(), 0) + + v = int("".join(openstudio.openStudioVersion().split("."))) + + p1 = openstudio.Point3dVector() + p2 = openstudio.Point3dVector() + + p1.append(openstudio.Point3d(3.63, 0, 4.03)) + p1.append(openstudio.Point3d(3.63, 0, 2.44)) + p1.append(openstudio.Point3d(7.34, 0, 2.44)) + p1.append(openstudio.Point3d(7.34, 0, 4.03)) + + t = openstudio.Transformation.alignFace(p1) + + if v < 340: + p2.append(openstudio.Point3d(3.63, 0, 2.49)) + p2.append(openstudio.Point3d(3.63, 0, 1.00)) + p2.append(openstudio.Point3d(7.34, 0, 1.00)) + p2.append(openstudio.Point3d(7.34, 0, 2.49)) + else: + p2.append(openstudio.Point3d(3.63, 0, 2.47)) + p2.append(openstudio.Point3d(3.63, 0, 1.00)) + p2.append(openstudio.Point3d(7.34, 0, 1.00)) + p2.append(openstudio.Point3d(7.34, 0, 2.47)) + + area1 = openstudio.getArea(p1) + area2 = openstudio.getArea(p2) + self.assertTrue(area1) + self.assertTrue(area2) + area1 = area1.get() + area2 = area2.get() + + p1a = list(t.inverse() * p1) + p2a = list(t.inverse() * p2) + p1a.reverse() + p2a.reverse() + + union = openstudio.join(p1a, p2a, TOL2) + self.assertTrue(union) + union = union.get() + area = openstudio.getArea(union) + self.assertTrue(area) + area = area.get() + delta = area1 + area2 - area + + res = openstudio.intersect(p1a, p2a, TOL) + self.assertTrue(res) + res = res.get() + res1 = res.polygon1() + self.assertTrue(res1) + + res1_m2 = openstudio.getArea(res1) + self.assertTrue(res1_m2) + res1_m2 = res1_m2.get() + self.assertAlmostEqual(res1_m2, delta, places=2) + # self.assertTrue(mod1.doesOverlap(p1a, p2a)) + # self.assertEqual(o.status(), 0) def test24_triangulation(self): o = osut.oslg @@ -2407,41 +2468,26 @@ def test24_triangulation(self): a_vtx.reverse() results = openstudio.computeTriangulation(a_vtx, holes) self.assertEqual(len(results), 1) - vtx0 = list(results[0]) - vtx0.reverse() + # vtx0 = list(results[0]) + # vtx0.reverse() # for vt0 in vtx0: print(vt0) # == initial triangle, yet flat. # [20, 10, 0] # [ 0, 10, 0] # [ 0, 0, 0] vtx.append(openstudio.Point3d(20, 0, 0)) - # vtx << OpenStudio::Point3d.new(20, 0, 0) - # t = OpenStudio::Transformation.alignFace(vtx) - # a_vtx = (t.inverse * vtx).reverse - # results = OpenStudio.computeTriangulation(a_vtx, holes) - # expect(results.size).to eq(2) - # results.each { |result| puts result } - # [ 0, 10, 0] - # [20, 10, 0] - # [20, 0, 0] - # - # [ 0, 0, 0] - # [ 0, 10, 0] - # [20, 0, 0] t = openstudio.Transformation.alignFace(vtx) a_vtx = list(t.inverse() * vtx) a_vtx.reverse() results = openstudio.computeTriangulation(a_vtx, holes) self.assertEqual(len(results), 2) - vtx0 = list(results[0]) - vtx1 = list(results[0]) - # for vt0 in vtx0: print(vt0) + # for vt0 in list(results[0]): print(vt0) # [ 0, 10, 0] # [20, 10, 0] # [20, 0, 0] - # for vt1 in vtx1: print(vt1) + # for vt1 in list(results[1]): print(vt1) + # [ 0, 0, 0] # [ 0, 10, 0] - # [20, 10, 0] # [20, 0, 0] def test25_segments_triads_orientation(self): @@ -2549,7 +2595,6 @@ def test25_segments_triads_orientation(self): pt = osut.lineIntersection([p0, p2], [p6, p4]) self.assertTrue(osut.areSame(pt, p5)) - def test26_ulc_blc(self): o = osut.oslg self.assertEqual(o.status(), 0) From 9f41e35ed7e44fe20434ce4fcf41608a556f03e8 Mon Sep 17 00:00:00 2001 From: brgix Date: Mon, 14 Jul 2025 14:48:30 -0400 Subject: [PATCH 12/49] First try at 'overlaps' - buggy for now --- src/osut/osut.py | 170 +++++++++++++++++++++++++++++++++++++++++++-- tests/test_osut.py | 2 +- 2 files changed, 166 insertions(+), 6 deletions(-) diff --git a/src/osut/osut.py b/src/osut/osut.py index c21d3f7..c343c6d 100644 --- a/src/osut/osut.py +++ b/src/osut/osut.py @@ -3610,11 +3610,11 @@ def poly(pts=None, vx=False, uq=False, co=False, tt=False, sq="no") -> openstudi if not pln.pointOnPlane(pt): return oslg.empty("plane", mth, CN.ERR, v) t = openstudio.Transformation.alignFace(pts) - at = t.inverse() * pts + at = list(t.inverse() * pts) at.reverse() if isinstance(tt, cl): - att = tt.inverse() * pts + att = list(tt.inverse() * pts) att.reverse() if areSame(at, att): @@ -3628,7 +3628,7 @@ def poly(pts=None, vx=False, uq=False, co=False, tt=False, sq="no") -> openstudi t = openstudio.Transformation.alignFace(att) if t: - a = t.inverse() * att + a = list(t.inverse() * att) a.reverse() else: a = att @@ -3717,13 +3717,13 @@ def isPointWithinPolygon(p0=None, s=[], entirely=False) -> bool: s = poly(s, False, True, True) if not s: return oslg.empty("polygon", mth, CN.DBG, False) - n = OpenStudio.getOutwardNormal(s) + n = openstudio.getOutwardNormal(s) if not n: return oslg.invalid("plane/normal", mth, 2, CN.DBG, False) n = n.get() pl = openstudio.Plane(s[0], n) if not pl.pointOnPlane(p0): return False - if not isinstance(entireley, bool): entirely = False + if not isinstance(entirely, bool): entirely = False segs = segments(s) @@ -3931,6 +3931,166 @@ def isSquare(pts=None) -> bool: return True +def fits(p1=None, p2=None, entirely=False) -> bool: + """Determines whether a 1st OpenStudio polygon (p1) fits within a 2nd + polygon (p2). Vertex sequencing of both polygons must be counterclockwise. + If option 'entirely' is True, then the method returns False if a 'p1' point + lies along any of the 'p2' polygon edges, or is very near any of its + vertices. + + Args: + p1 (openstudio.Point3d): + 1st OpenStudio vector of 3D points. + p2 (openstudio.Point3d): + 2nd OpenStudio vector of 3D points. + entirely (bool): + Whether point should be neatly within polygon limits. + + Returns: + bool: Whether 1st polygon fits within the 2nd polygon. + False: If invalid input (see logs). + + """ + pts = [] + p1 = poly(p1) + p2 = poly(p2) + if not p1: return False + if not p2: return False + + for p0 in p1: + if not isPointWithinPolygon(p0, p2): return False + + # Although p2 points may lie ALONG p1, none may lie entirely WITHIN p1. + for p0 in p2: + if isPointWithinPolygon(p0, p1): return False + + # p1 segment mid-points must not lie OUTSIDE of p2. + for sg in segments(p1): + mp = midpoint(sg[0], sg[1]) + if not isPointWithinPolygon(mp, p2): return False + + if not isinstance(entirely, bool): entirely = False + if not entirely: return True + + for p0 in p1: + if not isPointWithinPolygon(p0, p2, entirely): return False + + return True + + +def overlaps(p1=None, p2=None, flat=False) -> bool: + """Returns intersection of overlapping polygons, empty if non intersecting. + If the optional 3rd argument is left as False, the 2nd polygon may only + overlap if it shares the 3D plane equation of the 1st one. If the 3rd + argument is instead set to True, then the 2nd polygon is first 'cast' onto + the 3D plane of the 1st one; the method therefore returns (as overlap) the + intersection of a 'projection' of the 2nd polygon onto the 1st one. The + method returns the smallest of the 2 polygons if either fits within the + larger one. + + Args: + p1 (openstudio.Point3d): + 1st OpenStudio vector of 3D points. + p2 (openstudio.Point3d): + 2nd OpenStudio vector of 3D points. + flat (bool): + Whether to first project the 2nd set onto the 1st set plane. + + Returns: + openstudio.Point3dVector: Largest intersection (see logs if empty). + + """ + mth = "osut.overlap" + face = openstudio.Point3dVector() + p01 = poly(p1) + p02 = poly(p2) + if not p01: return oslg.empty("points 1", mth, CN.DBG, face) + if not p02: return oslg.empty("points 2", mth, CN.DBG, face) + if fits(p01, p02): return p01 + if fits(p02, p01): return p02 + + if not isinstance(flat, bool): flat = False + + if shareXYZ(p01, "z"): + t = None + a1 = list(p01) + a2 = list(p02) + cw1 = isClockwise(p01) + if cw1: a1.reverse() + if flat: a2 = flatten(a2) + + if not shareXYZ(a2, "z"): + return invalid("points 2", mth, 2, CN.DBG, face) + + cw2 = isClockwise(a2) + if cw2: a2.reverse() + else: + t = openstudio.Transformation.alignFace(p01) + a1 = t.inverse() * p01 + a2 = t.inverse() * p02 + if flat: a2 = list(flatten(a2)) + + if not shareXYZ(a2, "z"): + return invalid("points 2", mth, 2, CN.DBG, face) + + cw2 = isClockwise(a2) + if cw2: a2.reverse() + + # Return either (transformed) polygon if one fits into the other. + p1t = p01 + + if t: + if not cw2: a2.reverse() + p2t = to_p3Dv(t * a2) + else: + if cw1: + if cw2: a2.reverse() + p2t = to_p3Dv(a2) + else: + if not cw2: a2.reverse() + p2t = to_p3Dv(a2) + + if fits(a1, a2): return p1t + if fits(a2, a1): return p2t + + area1 = openstudio.getArea(a1) + area2 = openstudio.getArea(a2) + + if not area1: return oslg.empty("points 1 area", mth, CN.ERR, face) + if not area2: return oslg.empty("points 2 area", mth, CN.ERR, face) + + area1 = area1.get() + area2 = area2.get() + a1.reverse() + a2.reverse() + union = openstudio.join(a1, a2, CN.TOL2) + if not union: return face + + union = union.get() + area = OpenStudio.getArea(union) + if not area: return face + + area = area.get() + delta = area1 + area2 - area + + if area > CN.TOL: + if round(area, 2) == round(area1, 2): return face + if round(area, 2) == round(area2, 2): return face + if round(delta, 2) == 0: return face + + res = openstudio.intersect(a1, a2, CN.TOL) + if not res: return face + + res = res.get() + res1 = res.polygon1() + if not res1: return face + + res1.reverse() + if t: res1 = t * res1 + + return to_p3Dv(res1) + + def facets(spaces=[], boundary="all", type="all", sides=[]) -> list: """Returns an array of OpenStudio space surfaces or subsurfaces that match criteria, e.g. exterior, north-east facing walls in hotel "lobby". Note diff --git a/tests/test_osut.py b/tests/test_osut.py index f52f2c4..708947c 100644 --- a/tests/test_osut.py +++ b/tests/test_osut.py @@ -2444,7 +2444,7 @@ def test23_fits_overlaps(self): self.assertTrue(res1_m2) res1_m2 = res1_m2.get() self.assertAlmostEqual(res1_m2, delta, places=2) - # self.assertTrue(mod1.doesOverlap(p1a, p2a)) + # self.assertTrue(osut.overlaps(p1a, p2a)) # self.assertEqual(o.status(), 0) def test24_triangulation(self): From 838fb59507eb705f38c30043b4cd9e1abdaec9f7 Mon Sep 17 00:00:00 2001 From: brgix Date: Mon, 14 Jul 2025 16:43:43 -0400 Subject: [PATCH 13/49] Fixes overlap method - initial tests only --- src/osut/osut.py | 52 ++++++++++++++++++++++++++++++++-------------- tests/test_osut.py | 5 +++-- 2 files changed, 39 insertions(+), 18 deletions(-) diff --git a/src/osut/osut.py b/src/osut/osut.py index c343c6d..e67e080 100644 --- a/src/osut/osut.py +++ b/src/osut/osut.py @@ -3978,7 +3978,7 @@ def fits(p1=None, p2=None, entirely=False) -> bool: return True -def overlaps(p1=None, p2=None, flat=False) -> bool: +def overlap(p1=None, p2=None, flat=False) -> bool: """Returns intersection of overlapping polygons, empty if non intersecting. If the optional 3rd argument is left as False, the 2nd polygon may only overlap if it shares the 3D plane equation of the 1st one. If the 3rd @@ -4011,19 +4011,17 @@ def overlaps(p1=None, p2=None, flat=False) -> bool: if not isinstance(flat, bool): flat = False + cw1 = isClockwise(p01) + t = None + if shareXYZ(p01, "z"): - t = None - a1 = list(p01) - a2 = list(p02) - cw1 = isClockwise(p01) + a1 = list(p01) + a2 = list(p02) if cw1: a1.reverse() - if flat: a2 = flatten(a2) + if flat: a2 = list(flatten(a2)) if not shareXYZ(a2, "z"): return invalid("points 2", mth, 2, CN.DBG, face) - - cw2 = isClockwise(a2) - if cw2: a2.reverse() else: t = openstudio.Transformation.alignFace(p01) a1 = t.inverse() * p01 @@ -4033,8 +4031,8 @@ def overlaps(p1=None, p2=None, flat=False) -> bool: if not shareXYZ(a2, "z"): return invalid("points 2", mth, 2, CN.DBG, face) - cw2 = isClockwise(a2) - if cw2: a2.reverse() + cw2 = isClockwise(a2) + if cw2: a2.reverse() # Return either (transformed) polygon if one fits into the other. p1t = p01 @@ -4044,11 +4042,11 @@ def overlaps(p1=None, p2=None, flat=False) -> bool: p2t = to_p3Dv(t * a2) else: if cw1: - if cw2: a2.reverse() - p2t = to_p3Dv(a2) - else: if not cw2: a2.reverse() - p2t = to_p3Dv(a2) + else: + if cw2: a2.reverse() + + p2t = to_p3Dv(a2) if fits(a1, a2): return p1t if fits(a2, a1): return p2t @@ -4063,11 +4061,12 @@ def overlaps(p1=None, p2=None, flat=False) -> bool: area2 = area2.get() a1.reverse() a2.reverse() + union = openstudio.join(a1, a2, CN.TOL2) if not union: return face union = union.get() - area = OpenStudio.getArea(union) + area = openstudio.getArea(union) if not area: return face area = area.get() @@ -4085,12 +4084,33 @@ def overlaps(p1=None, p2=None, flat=False) -> bool: res1 = res.polygon1() if not res1: return face + res1 = list(res1) res1.reverse() if t: res1 = t * res1 return to_p3Dv(res1) +def doesOverlap(p1=None, p2=None, flat=False): + """Determines whether OpenStudio polygons overlap. + + Args: + p1 (openstudio.Point3d): + 1st OpenStudio vector of 3D points. + p2 (openstudio.Point3d): + 2nd OpenStudio vector of 3D points. + flat (bool): + Whether to first project the 2nd set onto the 1st set plane. + + Returns: + bool: Whether polygons overlap (or fit). + False: If invalid input (see logs). + """ + if overlap(p1, p2, flat): return True + + return False + + def facets(spaces=[], boundary="all", type="all", sides=[]) -> list: """Returns an array of OpenStudio space surfaces or subsurfaces that match criteria, e.g. exterior, north-east facing walls in hotel "lobby". Note diff --git a/tests/test_osut.py b/tests/test_osut.py index 708947c..8fad60f 100644 --- a/tests/test_osut.py +++ b/tests/test_osut.py @@ -2444,8 +2444,9 @@ def test23_fits_overlaps(self): self.assertTrue(res1_m2) res1_m2 = res1_m2.get() self.assertAlmostEqual(res1_m2, delta, places=2) - # self.assertTrue(osut.overlaps(p1a, p2a)) - # self.assertEqual(o.status(), 0) + # olap = osut.overlap(p1a, p2a) + self.assertTrue(osut.doesOverlap(p1a, p2a)) + self.assertEqual(o.status(), 0) def test24_triangulation(self): o = osut.oslg From 34ad0386d9b33a131ed522ca5f77dac0928f8b39 Mon Sep 17 00:00:00 2001 From: brgix Date: Mon, 14 Jul 2025 16:46:10 -0400 Subject: [PATCH 14/49] Minor cleanup - post overlap fix --- tests/test_osut.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_osut.py b/tests/test_osut.py index 8fad60f..e5b32b6 100644 --- a/tests/test_osut.py +++ b/tests/test_osut.py @@ -2444,7 +2444,6 @@ def test23_fits_overlaps(self): self.assertTrue(res1_m2) res1_m2 = res1_m2.get() self.assertAlmostEqual(res1_m2, delta, places=2) - # olap = osut.overlap(p1a, p2a) self.assertTrue(osut.doesOverlap(p1a, p2a)) self.assertEqual(o.status(), 0) From 1f8df35a56321a439ed417146d83fad1e6f05069 Mon Sep 17 00:00:00 2001 From: brgix Date: Wed, 16 Jul 2025 06:28:35 -0400 Subject: [PATCH 15/49] Partial refactor of 'overlap' (tested) --- src/osut/osut.py | 185 ++++++++++++++++++++++++------------------ tests/test_osut.py | 197 +++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 295 insertions(+), 87 deletions(-) diff --git a/src/osut/osut.py b/src/osut/osut.py index e67e080..4167e8d 100644 --- a/src/osut/osut.py +++ b/src/osut/osut.py @@ -2510,7 +2510,7 @@ def scalar(v=None, m=0) -> openstudio.Vector3d: return v0 -def to_p3Dv(pts=None) -> openstudio.Point3dVector: +def p3Dv(pts=None) -> openstudio.Point3dVector: """Returns OpenStudio 3D points as an OpenStudio point vector, validating points in the process. @@ -2521,7 +2521,7 @@ def to_p3Dv(pts=None) -> openstudio.Point3dVector: openstudio.Point3dVector: Vector of 3D points (see logs if empty). """ - mth = "osut.to_p3Dv" + mth = "osut.p3Dv" cl = openstudio.Point3d v = openstudio.Point3dVector() @@ -2531,7 +2531,7 @@ def to_p3Dv(pts=None) -> openstudio.Point3dVector: elif isinstance(pts, openstudio.Point3dVector): return pts elif isinstance(pts, openstudio.model.PlanarSurface): - return pts.vertices() + pts = list(pts.vertices()) try: pts = list(pts) @@ -2564,8 +2564,8 @@ def areSame(s1=None, s2=None, indexed=True) -> bool: False: If invalid input (see logs). """ - s1 = list(to_p3Dv(s1)) - s2 = list(to_p3Dv(s2)) + s1 = list(p3Dv(s1)) + s2 = list(p3Dv(s2)) if not s1: return False if not s2: return False if len(s1) != len(s2): return False @@ -2618,7 +2618,7 @@ def holds(pts=None, p1=None) -> bool: """ mth = "osut.holds" - pts = to_p3Dv(pts) + pts = p3Dv(pts) cl = openstudio.Point3d if not isinstance(p1, cl): @@ -2654,7 +2654,7 @@ def nearest(pts=None, p01=None): d02 = 0 d03 = 0 idx = None - pts = to_p3Dv(pts) + pts = p3Dv(pts) if not pts: return idx p03 = openstudio.Point3d( l,-l,-l) @@ -2715,7 +2715,7 @@ def farthest(pts=None, p01=None): d02 = 10000 d03 = 10000 idx = None - pts = to_p3Dv(pts) + pts = p3Dv(pts) if not pts: return idx p03 = openstudio.Point3d( l,-l,-l) @@ -2766,7 +2766,7 @@ def flatten(pts=None, axs="z", val=0) -> openstudio.Point3dVector: openstudio.Point3dVector: flattened points (see logs if empty) """ mth = "osut.flatten" - pts = to_p3Dv(pts) + pts = p3Dv(pts) v = openstudio.Point3dVector() try: @@ -2808,7 +2808,7 @@ def shareXYZ(pts=None, axs="z", val=0) -> bool: """ mth = "osut.shareXYZ" - pts = to_p3Dv(pts) + pts = p3Dv(pts) if not pts: return False try: @@ -2851,7 +2851,7 @@ def nextUp(pts=None, pt=None): """ mth = "osut.nextUP" - pts = to_p3Dv(pts) + pts = p3Dv(pts) cl = openstudio.Point3d if not isinstance(pt, cl): @@ -2877,7 +2877,7 @@ def width(pts=None) -> float: float: 'Width' along X-axis. 0.0: If invalid input (see logs). """ - pts = to_p3Dv(pts) + pts = p3Dv(pts) if len(pts) < 2: return 0 xs = [pt.x() for pt in pts] @@ -2897,7 +2897,7 @@ def height(pts=None) -> float: float: 'Height' along Z-axis, or Y-axis if points are flat. 0.0: If invalid input (see logs). """ - pts = to_p3Dv(pts) + pts = p3Dv(pts) if len(pts) < 2: return 0 zs = [pt.z() for pt in pts] @@ -2993,7 +2993,7 @@ def uniques(pts=None, n=0) -> openstudio.Point3dVector: """ mth = "osut.uniques" - pts = to_p3Dv(pts) + pts = p3Dv(pts) v = openstudio.Point3dVector() if not pts: return v @@ -3057,7 +3057,7 @@ def isSegment(pts=None) -> bool: False: If invalid input (see logs). """ - pts = to_p3Dv(pts) + pts = p3Dv(pts) if len(pts) != 2: return False if areSame(pts[0], pts[1]): return False @@ -3112,7 +3112,7 @@ def isTriad(pts=None) -> bool: False: If invalid input (see logs). """ - pts = to_p3Dv(pts) + pts = p3Dv(pts) if len(pts) != 3: return False if areSame(pts[0], pts[1]): return False if areSame(pts[0], pts[2]): return False @@ -3344,7 +3344,7 @@ def isClockwise(pts=None) -> bool: """ mth = "osut.isClockwise" - pts = to_p3Dv(pts) + pts = p3Dv(pts) if len(pts) < 3: return oslg.invalid("3+ points", mth, 1, CN.DBG, False) @@ -3375,7 +3375,7 @@ def ulc(pts=None) -> openstudio.Point3dVector: """ mth = "osut.ulc" v = openstudio.Point3dVector() - pts = list(to_p3Dv(pts)) + pts = list(p3Dv(pts)) if len(pts) < 3: return oslg.invalid("points (3+)", mth, 1, CN.DBG, v) @@ -3400,7 +3400,7 @@ def ulc(pts=None) -> openstudio.Point3dVector: pts = collections.deque(pts) pts.rotate(-i1) - return to_p3Dv(list(pts)) + return p3Dv(list(pts)) def blc(pts=None) -> openstudio.Point3dVector: @@ -3417,7 +3417,7 @@ def blc(pts=None) -> openstudio.Point3dVector: """ mth = "osut.blc" v = openstudio.Point3dVector() - pts = list(to_p3Dv(pts)) + pts = list(p3Dv(pts)) if len(pts) < 3: return oslg.invalid("points (3+)", mth, 1, CN.DBG, v) @@ -3438,7 +3438,7 @@ def blc(pts=None) -> openstudio.Point3dVector: if p0 in pts_x: pts = collections.deque(pts) pts.rotate(-i0) - return to_p3Dv(list(pts)) + return p3Dv(list(pts)) for pt in pts_x: if round((pt - p0).length(), 2) < round((p1 - p0).length(), 2): p1 = pt @@ -3447,7 +3447,7 @@ def blc(pts=None) -> openstudio.Point3dVector: pts = collections.deque(pts) pts.rotate(-i1) - return to_p3Dv(list(pts)) + return p3Dv(list(pts)) def nonCollinears(pts=None, n=0) -> openstudio.Point3dVector: @@ -3499,13 +3499,13 @@ def nonCollinears(pts=None, n=0) -> openstudio.Point3dVector: a.rotate(1) a = list(a) - if n > len(a): return to_p3Dv(a) - if n < 0 and abs(n) > len(a): return to_p3Dv(a) + if n > len(a): return p3Dv(a) + if n < 0 and abs(n) > len(a): return p3Dv(a) if n > 0: a = a[0:n] if n < 0: a = a[n:] - return to_p3Dv(a) + return p3Dv(a) def collinears(pts=None, n=0) -> openstudio.Point3dVector: @@ -3544,13 +3544,13 @@ def collinears(pts=None, n=0) -> openstudio.Point3dVector: for pt in pts: if pt not in ncolls: a.append(pt) - if n > len(a): return to_p3Dv(a) - if n < 0 and abs(n) > len(a): return to_p3Dv(a) + if n > len(a): return p3Dv(a) + if n < 0 and abs(n) > len(a): return p3Dv(a) if n > 0: a = a[0:n] if n < 0: a = a[n:] - return to_p3Dv(a) + return p3Dv(a) def poly(pts=None, vx=False, uq=False, co=False, tt=False, sq="no") -> openstudio.Point3dVector: @@ -3580,7 +3580,7 @@ def poly(pts=None, vx=False, uq=False, co=False, tt=False, sq="no") -> openstudi """ mth = "osut.poly" - pts = to_p3Dv(pts) + pts = p3Dv(pts) cl = openstudio.Transformation v = openstudio.Point3dVector() sqs = ["no", "ulc", "blc", "cw"] @@ -3670,22 +3670,22 @@ def poly(pts=None, vx=False, uq=False, co=False, tt=False, sq="no") -> openstudi if isinstance(tt, cl): if sq == "ulc": - a = to_p3Dv(t * ulc(a)) if t else to_p3Dv(ulc(a)) + a = p3Dv(t * ulc(a)) if t else p3Dv(ulc(a)) elif sq == "blc": - a = to_p3Dv(t * blc(a)) if t else to_p3Dv(blc(a)) + a = p3Dv(t * blc(a)) if t else p3Dv(blc(a)) elif sq == "cw": - a = to_p3Dv(t * a) if t else to_p3Dv(a) + a = p3Dv(t * a) if t else p3Dv(a) else: - a = to_p3Dv(t * a) if t else to_p3Dv(a) + a = p3Dv(t * a) if t else p3Dv(a) else: if sq == "ulc": - a = to_p3Dv(ulc(a)) if tt else to_p3Dv(t * ulc(a)) + a = p3Dv(ulc(a)) if tt else p3Dv(t * ulc(a)) elif sq == "blc": - a = to_p3Dv(blc(a)) if tt else to_p3Dv(t * blc(a)) + a = p3Dv(blc(a)) if tt else p3Dv(t * blc(a)) elif sq == "cw": - a = to_p3Dv(a) if tt else to_p3Dv(t * a) + a = p3Dv(a) if tt else p3Dv(t * a) else: - a = to_p3Dv(a) if tt else to_p3Dv(t * a) + a = p3Dv(a) if tt else p3Dv(t * a) return a @@ -3783,8 +3783,8 @@ def areParallel(p1=None, p2=None) -> bool: if not p1: return False if not p2: return False - n1 = OpenStudio.getOutwardNormal(p1) - n2 = OpenStudio.getOutwardNormal(p2) + n1 = openstudio.getOutwardNormal(p1) + n2 = openstudio.getOutwardNormal(p2) if not n1: return False if not n2: return False @@ -3962,7 +3962,7 @@ def fits(p1=None, p2=None, entirely=False) -> bool: # Although p2 points may lie ALONG p1, none may lie entirely WITHIN p1. for p0 in p2: - if isPointWithinPolygon(p0, p1): return False + if isPointWithinPolygon(p0, p1, True): return False # p1 segment mid-points must not lie OUTSIDE of p2. for sg in segments(p1): @@ -4001,6 +4001,7 @@ def overlap(p1=None, p2=None, flat=False) -> bool: """ mth = "osut.overlap" + t = None face = openstudio.Point3dVector() p01 = poly(p1) p02 = poly(p2) @@ -4008,48 +4009,24 @@ def overlap(p1=None, p2=None, flat=False) -> bool: if not p02: return oslg.empty("points 2", mth, CN.DBG, face) if fits(p01, p02): return p01 if fits(p02, p01): return p02 - if not isinstance(flat, bool): flat = False - cw1 = isClockwise(p01) - t = None - if shareXYZ(p01, "z"): a1 = list(p01) a2 = list(p02) - if cw1: a1.reverse() - if flat: a2 = list(flatten(a2)) - - if not shareXYZ(a2, "z"): - return invalid("points 2", mth, 2, CN.DBG, face) + if isClockwise(p01): a1.reverse() else: - t = openstudio.Transformation.alignFace(p01) - a1 = t.inverse() * p01 - a2 = t.inverse() * p02 - if flat: a2 = list(flatten(a2)) - - if not shareXYZ(a2, "z"): - return invalid("points 2", mth, 2, CN.DBG, face) + t = openstudio.Transformation.alignFace(p01) + cw1 = False + a1 = list(t.inverse() * p01) + a2 = list(t.inverse() * p02) - cw2 = isClockwise(a2) - if cw2: a2.reverse() - - # Return either (transformed) polygon if one fits into the other. - p1t = p01 - - if t: - if not cw2: a2.reverse() - p2t = to_p3Dv(t * a2) - else: - if cw1: - if not cw2: a2.reverse() - else: - if cw2: a2.reverse() + if flat: a2 = list(flatten(a2)) - p2t = to_p3Dv(a2) + if not shareXYZ(a2, "z"): + return invalid("points 2", mth, 2, CN.DBG, face) - if fits(a1, a2): return p1t - if fits(a2, a1): return p2t + if isClockwise(a2): a2.reverse() area1 = openstudio.getArea(a1) area2 = openstudio.getArea(a2) @@ -4081,17 +4058,15 @@ def overlap(p1=None, p2=None, flat=False) -> bool: if not res: return face res = res.get() - res1 = res.polygon1() - if not res1: return face - - res1 = list(res1) + res1 = list(res.polygon1()) res1.reverse() - if t: res1 = t * res1 + if not res1: return face + if t: res1 = list(t * res1) - return to_p3Dv(res1) + return p3Dv(res1) -def doesOverlap(p1=None, p2=None, flat=False): +def overlapping(p1=None, p2=None, flat=False): """Determines whether OpenStudio polygons overlap. Args: @@ -4111,6 +4086,56 @@ def doesOverlap(p1=None, p2=None, flat=False): return False +def cast(p1=None, p2=None, ray=None) -> openstudio.Point3dVector: + """Casts an OpenStudio polygon onto the 3D plane of a 2nd polygon, relying + on an independent 3D ray vector. + + Args: + p1 (openstudio.Point3dVector): + 1st OpenStudio vector of 3D points. + p2 (openstudio.Point3dvector): + 2nd OpenStudio vector of 3D points. + ray (openstudio.Point3d): + A 3D vector. + + Returns: + (openstudio.Point3dVector): Cast of p1 onto p2 (see logs if empty). + + """ + mth = "osut.cast" + cl = openstudio.Vector3d + face = openstudio.Point3dVector() + p1 = poly(p1) + p2 = poly(p2) + if not p1: return face + if not p2: return face + + if not isinstance(ray, cl): + return oslg.mismatch("ray", ray, cl, mth, CN.DBG, face) + + # From OpenStudio SDK v3.7.0 onwards, one could/should rely on: + # + # s3.amazonaws.com/openstudio-sdk-documentation/cpp/OpenStudio-3.7.0-doc/ + # utilities/html/classopenstudio_1_1_plane.html + # #abc4747b1b041a7f09a6887bc0e5abce1 + # + # Example Ruby implementation. + # e.g. p1.each { |pt| face << pl.rayIntersection(pt, ray) } + # + # The following +/- replicates the same solution, based on: + # https://stackoverflow.com/a/65832417 + p0 = p2[0] + pl = openstudio.Plane(p2) + n = pl.outwardNormal() + if abs(n.dot(ray)) < CN.TOL: return face + + for pt in p1: + length = n.dot(pt - p0) / n.dot(ray.reverseVector()) + face.append(pt) + scalar(ray, length) + + return face + + def facets(spaces=[], boundary="all", type="all", sides=[]) -> list: """Returns an array of OpenStudio space surfaces or subsurfaces that match criteria, e.g. exterior, north-east facing walls in hotel "lobby". Note diff --git a/tests/test_osut.py b/tests/test_osut.py index e5b32b6..50dd871 100644 --- a/tests/test_osut.py +++ b/tests/test_osut.py @@ -28,6 +28,7 @@ # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. import math +import collections import unittest import openstudio from src.osut import osut @@ -98,7 +99,7 @@ def test05_construction_generation(self): self.assertEqual(len(o.logs()),1) self.assertEqual(o.logs()[0]["level"], DBG) self.assertEqual(o.logs()[0]["message"], m1) - self.assertTrue(o.clean(), DBG) + self.assertEqual(o.clean(), DBG) self.assertFalse(o.logs()) self.assertEqual(o.status(), 0) del(model) @@ -110,7 +111,7 @@ def test05_construction_generation(self): self.assertEqual(len(o.logs()),1) self.assertEqual(o.logs()[0]["level"], DBG) self.assertTrue(o.logs()[0]["message"], m2) - self.assertTrue(o.clean(), DBG) + self.assertEqual(o.clean(), DBG) self.assertFalse(o.logs()) self.assertEqual(o.status(), 0) del(model) @@ -836,7 +837,7 @@ def test07_construction_thickness(self): self.assertTrue(th > 0) self.assertTrue(o.is_error()) - self.assertTrue(o.clean(), DBG) + self.assertEqual(o.clean(), DBG) self.assertEqual(o.status(), 0) self.assertFalse(o.logs()) @@ -1843,7 +1844,7 @@ def test19_vestibules(self): self.assertTrue(o.is_error()) self.assertEqual(len(o.logs()), 1) self.assertEqual(o.logs()[0]["message"], msg) - self.assertTrue(o.clean(), DBG) + self.assertEqual(o.clean(), DBG) self.assertTrue(entry.additionalProperties().resetFeature(tag)) # Successful attempts. @@ -2444,9 +2445,191 @@ def test23_fits_overlaps(self): self.assertTrue(res1_m2) res1_m2 = res1_m2.get() self.assertAlmostEqual(res1_m2, delta, places=2) - self.assertTrue(osut.doesOverlap(p1a, p2a)) + self.assertTrue(osut.overlapping(p1a, p2a)) self.assertEqual(o.status(), 0) + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + # Tests line intersecting line segments. + sg1 = openstudio.Point3dVector() + sg1.append(openstudio.Point3d(18, 0, 0)) + sg1.append(openstudio.Point3d( 8, 3, 0)) + + sg2 = openstudio.Point3dVector() + sg2.append(openstudio.Point3d(12, 14, 0)) + sg2.append(openstudio.Point3d(12, 6, 0)) + + self.assertFalse(osut.lineIntersection(sg1, sg2)) + + sg1 = openstudio.Point3dVector() + sg1.append(openstudio.Point3d(0.60,19.06, 0)) + sg1.append(openstudio.Point3d(0.60, 0.60, 0)) + sg1.append(openstudio.Point3d(0.00, 0.00, 0)) + sg1.append(openstudio.Point3d(0.00,19.66, 0)) + + sg2 = openstudio.Point3dVector() + sg2.append(openstudio.Point3d(9.83, 9.83, 0)) + sg2.append(openstudio.Point3d(0.00, 0.00, 0)) + sg2.append(openstudio.Point3d(0.00,19.66, 0)) + + self.assertTrue(osut.areSame(sg1[2], sg2[1])) + self.assertTrue(osut.areSame(sg1[3], sg2[2])) + self.assertTrue(osut.fits(sg1, sg2)) + self.assertFalse(osut.fits(sg2, sg1)) + self.assertTrue(osut.areSame(osut.overlap(sg1, sg2), sg1)) + self.assertTrue(osut.areSame(osut.overlap(sg2, sg1), sg1)) + + for i, pt in enumerate(sg1): + self.assertTrue(osut.isPointWithinPolygon(pt, sg2)) + + # Note: As of OpenStudio v340, the following method is available as an + # all-in-one solution to check if a polygon fits within another polygon. + # + # answer = OpenStudio.polygonInPolygon(aligned_door, aligned_wall, TOL) + # + # As with other Boost-based methods, it requires 'aligned' surfaces + # (using OpenStudio Transformation' alignFace method), and set in a + # clockwise sequence. OSut sticks to fits? as it executes these steps + # behind the scenes, and is consistent for pre-v340 implementations. + model = openstudio.model.Model() + + # 10m x 10m parent vertical (wall) surface. + vec = openstudio.Point3dVector() + vec.append(openstudio.Point3d( 0, 0, 10)) + vec.append(openstudio.Point3d( 0, 0, 0)) + vec.append(openstudio.Point3d( 10, 0, 0)) + vec.append(openstudio.Point3d( 10, 0, 10)) + wall = openstudio.model.Surface(vec, model) + + # Side test: point alignment detection, 'w12' == wall/floor edge. + w1 = vec[1] + w2 = vec[2] + w12 = w2 - w1 + + # Side test: same? + vec2 = list(osut.p3Dv(vec)) + self.assertNotEqual(vec, vec2) + self.assertTrue(osut.areSame(vec, vec2)) + + vec2 = collections.deque(vec2) + vec2.rotate(-2) + vec2 = list(vec2) + self.assertTrue(osut.areSame(vec, vec2)) + self.assertFalse(osut.areSame(vec, vec2, False)) + + # 1m x 2m corner door (with 2x edges along wall edges), 4mm sill. + vec = openstudio.Point3dVector() + vec.append(openstudio.Point3d( 0.5, 0, 2.000)) + vec.append(openstudio.Point3d( 0.5, 0, 0.004)) + vec.append(openstudio.Point3d( 1.5, 0, 0.004)) + vec.append(openstudio.Point3d( 1.5, 0, 2.000)) + door1 = openstudio.model.SubSurface(vec, model) + + # Side test: point alignment detection: + # 'd1_w1': vector from door sill to wall corner 1 ( 0,0,0) + # 'd1_w2': vector from door sill to wall corner 1 (10,0,0) + d1 = vec[1] + d2 = vec[2] + d1_w1 = w1 - d1 + d1_w2 = w2 - d1 + self.assertTrue(osut.isPointAlongSegments(d1, [w1, w2])) + + # Order of arguments matter. + self.assertTrue(osut.fits(door1, wall)) + self.assertTrue(osut.overlapping(door1, wall)) + self.assertFalse(osut.fits(wall, door1)) + self.assertTrue(osut.overlapping(wall, door1)) + + # The method 'fits' offers an optional 3rd argument: whether a smaller + # polygon (e.g. door1) needs to 'entirely' fit within the larger + # polygon. Here, door1 shares its sill with the host wall (as its + # within 10mm of the wall bottom edge). + self.assertFalse(osut.fits(door1, wall, True)) + + # Another 1m x 2m corner door, yet entirely beyond the wall surface. + vec = openstudio.Point3dVector() + vec.append(openstudio.Point3d( 16, 0, 2)) + vec.append(openstudio.Point3d( 16, 0, 0)) + vec.append(openstudio.Point3d( 17, 0, 0)) + vec.append(openstudio.Point3d( 17, 0, 2)) + door2 = openstudio.model.SubSurface(vec, model) + + # Door2 fits?, overlaps? Order of arguments doesn't matter. + self.assertFalse(osut.fits(door2, wall)) + self.assertFalse(osut.overlapping(door2, wall)) + self.assertFalse(osut.fits(wall, door2)) + self.assertFalse(osut.overlapping(wall, door2)) + + # Top-right corner 2m x 2m window, overlapping top-right corner of wall. + vec = openstudio.Point3dVector() + vec.append(openstudio.Point3d( 9, 0, 11)) + vec.append(openstudio.Point3d( 9, 0, 9)) + vec.append(openstudio.Point3d( 11, 0, 9)) + vec.append(openstudio.Point3d( 11, 0, 11)) + window = openstudio.model.SubSurface(vec, model) + + # Window fits?, overlaps? + self.assertFalse(osut.fits(window, wall)) + olap = osut.overlap(window, wall) + self.assertEqual(len(olap), 4) + self.assertTrue(osut.fits(olap, wall)) + self.assertTrue(osut.overlapping(window, wall)) + self.assertFalse(osut.fits(wall, window)) + self.assertTrue(osut.overlapping(wall, window)) + + # A glazed surface, entirely encompassing the wall. + vec = openstudio.Point3dVector() + vec.append(openstudio.Point3d( 0, 0, 10)) + vec.append(openstudio.Point3d( 0, 0, 0)) + vec.append(openstudio.Point3d( 10, 0, 0)) + vec.append(openstudio.Point3d( 10, 0, 10)) + glazing = openstudio.model.SubSurface(vec, model) + + # Glazing fits?, overlaps? parallel? + self.assertTrue(osut.areParallel(glazing, wall)) + self.assertTrue(osut.fits(glazing, wall)) + self.assertTrue(osut.overlapping(glazing, wall)) + self.assertTrue(osut.areParallel(wall, glazing)) + self.assertTrue(osut.fits(wall, glazing)) + self.assertTrue(osut.overlapping(wall, glazing)) + + del(model) + self.assertEqual(o.clean(), DBG) + + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + # Checks overlaps when 2 surfaces don't share the same plane equation. + translator = openstudio.osversion.VersionTranslator() + + path = openstudio.path("./tests/files/osms/in/smalloffice.osm") + model = translator.loadModel(path) + self.assertTrue(model) + model = model.get() + + ceiling = model.getSurfaceByName("Core_ZN_ceiling") + floor = model.getSurfaceByName("Attic_floor_core") + roof = model.getSurfaceByName("Attic_roof_east") + soffit = model.getSurfaceByName("Attic_soffit_east") + south = model.getSurfaceByName("Attic_roof_south") + self.assertTrue(ceiling) + self.assertTrue(floor) + self.assertTrue(roof) + self.assertTrue(soffit) + self.assertTrue(south) + ceiling = ceiling.get() + floor = floor.get() + roof = roof.get() + soffit = soffit.get() + south = south.get() + + # Side test: triad, medial and bounded boxes. + # pts = mod1.getNonCollinears(ceiling.vertices, 3) + # box01 = mod1.triadBox(pts) + # box11 = mod1.boundedBox(ceiling) + # self.asserTrue(mod1.areSame(box01, box11) + # self.asserTrue(mod1.fits(box01, ceiling) + + del(model) + self.assertEqual(o.clean(), DBG) + def test24_triangulation(self): o = osut.oslg self.assertEqual(o.status(), 0) @@ -2547,13 +2730,13 @@ def test25_segments_triads_orientation(self): self.assertTrue(o.is_error()) self.assertEqual(len(o.logs()), 1) self.assertEqual(o.logs()[0]["message"], m1) - self.assertTrue(o.clean(), DBG) + self.assertEqual(o.clean(), DBG) collinears = osut.collinears([p0, p1, p2, p3, p8], -6) self.assertTrue(o.is_error()) self.assertEqual(len(o.logs()), 1) self.assertEqual(o.logs()[0]["message"], m2) - self.assertTrue(o.clean(), DBG) + self.assertEqual(o.clean(), DBG) # CASE a1: 2x end-to-end line segments (returns matching endpoints). self.assertTrue(osut.doesLineIntersect([p0, p1], [p1, p2])) From 8ea89c7eae651a5d3cfe87cb3f35f718a6a6c355 Mon Sep 17 00:00:00 2001 From: brgix Date: Wed, 16 Jul 2025 08:16:22 -0400 Subject: [PATCH 16/49] Adds (yet untested) 'offset' & 'outline' methods --- src/osut/osut.py | 357 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 355 insertions(+), 2 deletions(-) diff --git a/src/osut/osut.py b/src/osut/osut.py index 4167e8d..028e1ac 100644 --- a/src/osut/osut.py +++ b/src/osut/osut.py @@ -4030,7 +4030,6 @@ def overlap(p1=None, p2=None, flat=False) -> bool: area1 = openstudio.getArea(a1) area2 = openstudio.getArea(a2) - if not area1: return oslg.empty("points 1 area", mth, CN.ERR, face) if not area2: return oslg.empty("points 2 area", mth, CN.ERR, face) @@ -4066,7 +4065,7 @@ def overlap(p1=None, p2=None, flat=False) -> bool: return p3Dv(res1) -def overlapping(p1=None, p2=None, flat=False): +def overlapping(p1=None, p2=None, flat=False) -> bool: """Determines whether OpenStudio polygons overlap. Args: @@ -4136,6 +4135,360 @@ def cast(p1=None, p2=None, ray=None) -> openstudio.Point3dVector: return face +def offset(p1=None, w=0, v=0) -> openstudio.Point3dVector: + """Generates offset vertices (by width) for a 3- or 4-sided, convex polygon. + If width is negative, the vertices are contracted inwards. + + Args: + p1 (openstudio.Point3dVector): + OpenStudio vector of 3D points. + w (float): + Offset width (absolute min: 0.0254m). + v (int): + OpenStudio SDK version, eg '321' for "v3.2.1" (optional). + + Returns: + openstudio.Point3dVector: Offset points (see logs if unaltered). + + """ + mth = "osut.offset" + vs = int("".join(openstudio.openStudioVersion().split("."))) + pts = poly(p1, True, True, False, True, "cw") + + if len(pts) < 3 or len(pts) > 4: + return oslg.invalid("points", mth, 1, CN.DBG, p1) + elif len(pts) == 4: + iv = True + else: + iv = False + + try: + w = float(w) + except: + oslg.mismatch("width", w, float, mth) + w = 0 + + try: + v = int(v) + except: + oslg.mismatch("version", v, int, mth) + v = vs + + if abs(w) < 0.0254: return p1 + + if v >= 340: + t = openstudio.Transformation.alignFace(p1) + offset = openstudio.buffer(pts, w, CN.TOL) + if not offset: return p1 + + offset = offset.get() + offset.reverse() + return to_p3Dv(list(t * offset)) + else: # brute force approach + pz = {} + pz["A"] = {} + pz["B"] = {} + pz["C"] = {} + if iv: + pz["D"] = {} + + pz["A"]["p"] = openstudio.Point3d(p1[0].x(), p1[0].y(), p1[0].z()) + pz["B"]["p"] = openstudio.Point3d(p1[1].x(), p1[1].y(), p1[1].z()) + pz["C"]["p"] = openstudio.Point3d(p1[2].x(), p1[2].y(), p1[2].z()) + if iv: + pz["D"]["p"] = openstudio.Point3d(p1[3].x(), p1[3].y(), p1[3].z()) + + pzAp = pz["A"]["p"] + pzBp = pz["B"]["p"] + pzCp = pz["C"]["p"] + if iv: + pzDp = pz["D"]["p"] + + # Generate vector pairs, from next point & from previous point. + # :f_n : "from next" + # :f_p : "from previous" + # + # + # + # + # + # + # A <---------- B + # ^ + # \ + # \ + # C (or D) + # + pz["A"]["f_n"] = pzAp - pzBp + if iv: + pz["A"]["f_p"] = pzAp - pzDp + else: + pz["A"]["f_p"] = pzAp - pzCp + + pz["B"]["f_n"] = pzBp - pzCp + pz["B"]["f_p"] = pzBp - pzAp + + pz["C"]["f_p"] = pzCp - pzBp + if iv: + pz["C"]["f_n"] = pzCp - pzDp + else: + pz["C"]["f_n"] = pzCp - pzAp + + if iv: + pz["D"]["f_n"] = pzDp - pzAp + pz["D"]["f_p"] = pzDp - pzCp + + # Generate 3D plane from vectors. + # + # + # | <<< 3D plane ... from point A, with normal B>A + # | + # | + # | + # <---------- A <---------- B + # |\ + # | \ + # | \ + # | C (or D) + # + pz["A"]["pl_f_n"] = openstudio.Plane(pzAp, pz["A"]["f_n"]) + pz["A"]["pl_f_p"] = openstudio.Plane(pzAp, pz["A"]["f_p"]) + + pz["B"]["pl_f_n"] = openstudio.Plane(pzBp, pz["B"]["f_n"]) + pz["B"]["pl_f_p"] = openstudio.Plane(pzBp, pz["B"]["f_p"]) + + pz["C"]["pl_f_n"] = openstudio.Plane(pzCp, pz["C"]["f_n"]) + pz["C"]["pl_f_p"] = openstudio.Plane(pzCp, pz["C"]["f_p"]) + + if iv: + pz["D"]["pl_f_n"] = openstudio.Plane(pzDp, pz["D"]["f_n"]) + pz["D"]["pl_f_p"] = openstudio.Plane(pzDp, pz["D"]["f_p"]) + + # Project an extended point (pC) unto 3D plane. + # + # pC <<< projected unto extended B>A 3D plane + # eC | + # \ | + # \ | + # \| + # <---------- A <---------- B + # |\ + # | \ + # | \ + # | C (or D) + # + pz["A"]["p_n_pl"] = pz["A"]["pl_f_n"].project(pz["A"]["p"] + pz["A"]["f_p"]) + pz["A"]["n_p_pl"] = pz["A"]["pl_f_p"].project(pz["A"]["p"] + pz["A"]["f_n"]) + + pz["B"]["p_n_pl"] = pz["B"]["pl_f_n"].project(pz["B"]["p"] + pz["B"]["f_p"]) + pz["B"]["n_p_pl"] = pz["B"]["pl_f_p"].project(pz["B"]["p"] + pz["B"]["f_n"]) + + pz["C"]["p_n_pl"] = pz["C"]["pl_f_n"].project(pz["C"]["p"] + pz["C"]["f_p"]) + pz["C"]["n_p_pl"] = pz["C"]["pl_f_p"].project(pz["C"]["p"] + pz["C"]["f_n"]) + + if iv: + pz["D"]["p_n_pl"] = pz["D"]["pl_f_n"].project(pz["D"]["p"] + pz["D"]["f_p"]) + pz["D"]["n_p_pl"] = pz["D"]["pl_f_p"].project(pz["D"]["p"] + pz["D"]["f_n"]) + + # Generate vector from point (e.g. A) to projected extended point (pC). + # + # pC + # eC ^ + # \ | + # \ | + # \| + # <---------- A <---------- B + # |\ + # | \ + # | \ + # | C (or D) + # + pz["A"]["n_p_n_pl"] = pz["A"]["p_n_pl"] - pzAp + pz["A"]["n_n_p_pl"] = pz["A"]["n_p_pl"] - pzAp + + pz["B"]["n_p_n_pl"] = pz["B"]["p_n_pl"] - pzBp + pz["B"]["n_n_p_pl"] = pz["B"]["n_p_pl"] - pzBp + + pz["C"]["n_p_n_pl"] = pz["C"]["p_n_pl"] - pzCp + pz["C"]["n_n_p_pl"] = pz["C"]["n_p_pl"] - pzCp + + if iv: + pz["D"]["n_p_n_pl"] = pz["D"]["p_n_pl"] - pzDp + pz["D"]["n_n_p_pl"] = pz["D"]["n_p_pl"] - pzDp + + # Fetch angle between both extended vectors (A>pC & A>pB), + # ... then normalize (Cn). + # + # pC + # eC ^ + # \ | + # \ Cn + # \| + # <---------- A <---------- B + # |\ + # | \ + # | \ + # | C (or D) + # + a1 = openstudio.getAngle(pz["A"]["n_p_n_pl"], pz["A"]["n_n_p_pl"]) + a2 = openstudio.getAngle(pz["B"]["n_p_n_pl"], pz["B"]["n_n_p_pl"]) + a3 = openstudio.getAngle(pz["C"]["n_p_n_pl"], pz["C"]["n_n_p_pl"]) + if iv: + a4 = openstudio.getAngle(pz["D"]["n_p_n_pl"], pz["D"]["n_n_p_pl"]) + + # Generate new 3D points A', B', C' (and D') ... zigzag. + # + # + # + # + # A' ---------------------- B' + # \ + # \ A <---------- B + # \ \ + # \ \ + # \ \ + # C' C + pz["A"]["f_n"].normalize() + pz["A"]["n_p_n_pl"].normalize() + pzAp = pzAp + scalar(pz["A"]["n_p_n_pl"], w) + pzAp = pzAp + scalar(pz["A"]["f_n"], w * math.tan(a1/2)) + + pz["B"]["f_n"].normalize() + pz["B"]["n_p_n_pl"].normalize() + pzBp = pzBp + scalar(pz["B"]["n_p_n_pl"], w) + pzBp = pzBp + scalar(pz["B"]["f_n"], w * math.tan(a2/2)) + + pz["C"]["f_n"].normalize() + pz["C"]["n_p_n_pl"].normalize() + pzCp = pzCp + scalar(pz["C"]["n_p_n_pl"], w) + pzCp = pzCp + scalar(pz["C"]["f_n"], w * math.tan(a3/2)) + + if iv: + pz["D"]["f_n"].normalize() + pz["D"]["n_p_n_pl"].normalize() + pzDp = pzDp + scalar(pz["D"]["n_p_n_pl"], w) + pzDp = pzDp + scalar(pz["D"]["f_n"], w * math.tan(a4/2)) + + # Re-convert to OpenStudio 3D points. + vec = openstudio.Point3dVector() + vec.append(openstudio.Point3d(pzAp.x(), pzAp.y(), pzAp.z())) + vec.append(openstudio.Point3d(pzBp.x(), pzBp.y(), pzBp.z())) + vec.append(openstudio.Point3d(pzCp.x(), pzCp.y(), pzCp.z())) + if iv: + vec.append(openstudio.Point3d(pzDp.x(), pzDp.y(), pzDp.z())) + + return vec + + +def outline(a=[], bfr=0, flat=True) -> openstudio.Point3dVector: + """Generates a ULC OpenStudio 3D point vector (a bounding box) that + surrounds multiple (smaller) OpenStudio 3D point vectors. The generated, + 4-point outline is optionally buffered (or offset). Frame and Divider frame + widths are taken into account. + + Args: + a (list): + One or more sets of OpenStudio 3D points. + bfr (float): + An optional buffer size (min: 0.0254m). + flat (bool): + Whether points are to be pre-flattened (Z=0). + Returns: + openstudio.Point3dVector: ULC outline (see logs if empty). + + """ + mth = "osut.outline" + out = openstudio.Point3dVector() + xMIN = None + xMAX = None + yMIN = None + yMAX = None + a2 = [] + + try: + bfr = float(bfr) + except: + oslg.mismatch("buffer", bfr, float, mth) + bfr = 0 + + try: + flat = bool(flat) + except: + flat = True + + try: + a = list(a) + except: + return oslg.mismatch("array", a, list, mth, CN.DBG, out) + + if not a: return oslg.empty("array", mth, CN.DBG, out) + + vtx = poly(a[0]) + if not vtx: return out + if bfr < 0.0254: bfr = 0 + + t = openstudio.Transformation.alignFace(vtx) + + for pts in a: + points = poly(pts, False, True, False, t) + if flat: points = flatten(points) + if not points: continue + + a2.append(points) + + for pts in a2: + xs = [pt.x() for pt in pts] + ys = [pt.y() for pt in pts] + + minX = min(xs) + maxX = max(xs) + minY = min(ys) + maxY = max(ys) + + # Consider frame width, if frame-and-divider-enabled sub surface. + if hasattr(pts, "allowWindowPropertyFrameAndDivider"): + w = 0 + fd = pts.windowPropertyFrameAndDivider() + if fd: w = fd.get().frameWidth() + + if w > CN.TOL: + minX -= w + maxX += w + minY -= w + maxY += w + + if not xMIN: xMIN = minX + if not xMAX: xMAX = maxX + if not yMIN: yMIN = minY + if not yMAX: yMAX = maxY + + xMIN = min(xMIN, minX) + xMAX = max(xMAX, maxX) + yMIN = min(yMIN, minY) + yMAX = max(yMAX, maxY) + + if xMAX < xMIN: + return oslg.negative("outline width", mth, CN.DBG, out) + if yMAX < yMIN: + return oslg.negative("outline height", mth, Cn.DBG, out) + if abs(xMIN - xMAX) < TOL: + return oslg.zero("outline width", mth, CN.DBG, out) + if abs(yMIN - yMAX) < TOL: + return oslg.zero("outline height", mth, CN.DBG, out) + + # Generate ULC point 3D vector. + out.append(openstudio.Point3d(xMIN, yMAX, 0)) + out.append(openstudio.Point3d(xMIN, yMIN, 0)) + out.append(openstudio.Point3d(xMAX, yMIN, 0)) + out.append(openstudio.Point3d(xMAX, yMAX, 0)) + + # Apply buffer, apply ULC (options). + if bfr > 0.0254: out = offset(out, bfr, 300) + + return to_p3Dv(t * out) + + + def facets(spaces=[], boundary="all", type="all", sides=[]) -> list: """Returns an array of OpenStudio space surfaces or subsurfaces that match criteria, e.g. exterior, north-east facing walls in hotel "lobby". Note From 82028fb95af210c08fdc64b09f6c06b91bf1f5af Mon Sep 17 00:00:00 2001 From: brgix Date: Wed, 16 Jul 2025 14:47:29 -0400 Subject: [PATCH 17/49] Initial tests of 'bounded boxes' methods --- src/osut/osut.py | 339 ++++++++++++++++++++++++++++++++++++++++++++- tests/test_osut.py | 11 +- 2 files changed, 342 insertions(+), 8 deletions(-) diff --git a/src/osut/osut.py b/src/osut/osut.py index 028e1ac..5090892 100644 --- a/src/osut/osut.py +++ b/src/osut/osut.py @@ -4183,7 +4183,7 @@ def offset(p1=None, w=0, v=0) -> openstudio.Point3dVector: offset = offset.get() offset.reverse() - return to_p3Dv(list(t * offset)) + return p3Dv(list(t * offset)) else: # brute force approach pz = {} pz["A"] = {} @@ -4407,6 +4407,7 @@ def outline(a=[], bfr=0, flat=True) -> openstudio.Point3dVector: try: bfr = float(bfr) + if bfr < 0.0254: bfr = 0 except: oslg.mismatch("buffer", bfr, float, mth) bfr = 0 @@ -4425,7 +4426,6 @@ def outline(a=[], bfr=0, flat=True) -> openstudio.Point3dVector: vtx = poly(a[0]) if not vtx: return out - if bfr < 0.0254: bfr = 0 t = openstudio.Transformation.alignFace(vtx) @@ -4485,9 +4485,342 @@ def outline(a=[], bfr=0, flat=True) -> openstudio.Point3dVector: # Apply buffer, apply ULC (options). if bfr > 0.0254: out = offset(out, bfr, 300) - return to_p3Dv(t * out) + return p3Dv(t * out) +def triadBox(pts=None) -> openstudio.Point3dVector: + """Generates a BLC box from a triad (3D points). Points must be unique and + non-collinear. + + Args: + pts (openstudio.Point3dVector): + A 'triad' - an OpenStudio vector of 3x 3D points. + + Returns: + openstudio.Point3dVector: + A rectangular BLC box (see logs if empty). + + """ + mth = "osut.triadBox" + t = None + bkp = openstudio.Point3dVector() + box = [] + pts = nonCollinears(pts) + if not pts: return bkp + + if not shareXYZ(pts, "z"): + t = openstudio.Transformation.alignFace(pts) + pts = poly(pts, False, True, True, t) + if not pts: return bkp + + if len(pts) != 3: return oslg.invalid("triad", mth, 1, CN.ERR, bkp) + + if isClockwise(pts): + pts = list(pts) + pts.reverse() + pts = p3Dv(pts) + + p0 = pts[0] + p1 = pts[1] + p2 = pts[2] + + # Cast p0 unto vertical plane defined by p1/p2. + pp0 = verticalPlane(p1, p2).project(p0) + v00 = p0 - pp0 + v11 = pp0 - p1 + v10 = p0 - p1 + v12 = p2 - p1 + + # Reset p0 and/or p1 if obtuse or acute. + if v12.dot(v10) < 0: + p0 = p1 + v00 + elif v12.dot(v10) > 0: + if v11.length() < v12.length(): + p1 = pp0 + else: + p0 = p1 + v00 + + p3 = p2 + v00 + + box.append(openstudio.Point3d(p0.x(), p0.y(), p0.z())) + box.append(openstudio.Point3d(p1.x(), p1.y(), p1.z())) + box.append(openstudio.Point3d(p2.x(), p2.y(), p2.z())) + box.append(openstudio.Point3d(p3.x(), p3.y(), p3.z())) + + box = nonCollinears(box, 4) + if len(box) != 4: return bkp + + box = blc(box) + if not isRectangular(box): return bkp + + if t: box = p3Dv(t * box) + + return box + + +def medialBox(pts=None) -> openstudio.Point3dVector: + """Generates a BLC box bounded within a triangle (midpoint theorem). + + Args: + pts (openstudio.Point3dVector): + A triangular polygon. + + Returns: + openstudio.Point3dVector: A medial bounded box (see logs if empty). + """ + mth = "osut.medialBox" + t = None + bkp = openstudio.Point3dVector() + box = [] + pts = poly(pts, True, True, True) + if not pts: return bkp + if len(pts) != 3: return oslg.invalid("triangle", mth, 1, CN.ERR, bkp) + + if not shareXYZ(pts, "z"): + t = openstudio.Transformation.alignFace(pts) + pts = poly(pts, False, False, False, t) + if not pts: return bkp + + if isClockwise(pts): + pts.reverse() + pts = p3Dv(pts) + + # Generate vertical plane along longest segment. + sgs = segments(pts) + + mpoints = [] + longest = sgs[0] + distance = openstudio.getDistanceSquared(longest[0], longest[1]) + + for sg in sgs: + if sg == longest: continue + + d0 = openstudio.getDistanceSquared(sg[0], sg[1]) + + if distance < d0: + distance = d0 + longest = sg + + plane = verticalPlane(longest[0], longest[1]) + + # Fetch midpoints of other 2 segments. + for sg in sgs: + if sg != longest: mpoints.append(midpoint(sg[0], sg[1])) + + if len(mpoints) != 2: return bkp + + # Generate medial bounded box. + box.append(plane.project(mpoints[0])) + box.append(mpoints[0]) + box.append(mpoints[1]) + box.append(plane.project(mpoints[1])) + box = list(nonCollinears(box)) + if box.size != 4: return bkp + + if isClockwise(box): box.reverse() + + box = blc(box) + if not isRectangular(box): return bkp + if not fits(box, pts): return bkp + + if t: box = p3Dv(t * box) + + return box + + +def boundedBox(pts=None) -> openstudio.Point3dVector: + """Generates a BLC bounded box within a polygon. + + Args: + pts (openstudio.Point3dVector): + A set of OpenStudio 3D points. + + Returns: + openstudio.Point3dVector: A bounded box (see logs if empty). + """ + # str = ".*(? CN.TOL: + if t: box = p3Dv(t * box) + return box + + # PATH E : Medial box, segment approach. + aire = 0 + + for sg in segments(pts): + p0 = sg[0] + p1 = sg[1] + + for p2 in pts: + if areSame(p2, p0): continue + if areSame(p2, p1): continue + + out = medialBox(openstudioPoint3dVector([p0, p1, p2])) + if not out: continue + if not fits(out, pts): continue + if fits(pts, out): continue + + area = openstudio.getArea(box) + if not area: continue + + area = area.get() + if area < CN.TOL: continue + if area < aire: continue + + aire = area + box = out + + if aire > CN.TOL: + if t: box = p3Dv(t * box) + return box + + # PATH F : Medial box, triad approach. + aire = 0 + + for sg in triads(pts): + p0 = sg[0] + p1 = sg[1] + p2 = sg[2] + + out = medialBox(openstudio.Point3dVector([p0, p1, p2])) + if not out: continue + if not fits(out, pts): continue + if fits(pts, out): continue + + area = openstudio.getArea(box) + if not area: continue + + area = area.get() + if area < CN.TOL: continue + if area < aire: continue + + aire = area + box = out + + if aire > CN.TOL: + if t: box = p3Dv(t * box) + return box + + # PATH G : Medial box, triangulated approach. + aire = 0 + outer = list(pts) + outer.reverse() + outer = p3Dv(outer) + holes = openstudio.Point3dVectorVector() + + for triangle in openstudio.computeTriangulation(outer, holes): + for sg in segments(triangle): + p0 = sg[0] + p1 = sg[1] + + for p2 in pts: + if areSame(p2, p0): continue + if areSame(p2, p1): continue + + out = medialBox(openstudio.Point3dVector([p0, p1, p2])) + if not out: continue + if not fits(out, pts): continue + if fits(pts, out): continue + + area = openstudio.getArea(out) + if not area: continue + + area = area.get() + if area < CN.TOL: continue + if area < aire: continue + + aire = area + box = out + + if aire < CN.TOL: return bkp + if t: box = p3Dv(t * box) + + return box + def facets(spaces=[], boundary="all", type="all", sides=[]) -> list: """Returns an array of OpenStudio space surfaces or subsurfaces that match diff --git a/tests/test_osut.py b/tests/test_osut.py index 50dd871..2a3b716 100644 --- a/tests/test_osut.py +++ b/tests/test_osut.py @@ -2621,11 +2621,12 @@ def test23_fits_overlaps(self): south = south.get() # Side test: triad, medial and bounded boxes. - # pts = mod1.getNonCollinears(ceiling.vertices, 3) - # box01 = mod1.triadBox(pts) - # box11 = mod1.boundedBox(ceiling) - # self.asserTrue(mod1.areSame(box01, box11) - # self.asserTrue(mod1.fits(box01, ceiling) + pts = osut.nonCollinears(ceiling.vertices(), 3) + box01 = osut.triadBox(pts) + box11 = osut.boundedBox(ceiling) + print(o.logs()) + self.assertTrue(osut.areSame(box01, box11)) + self.assertTrue(osut.fits(box01, ceiling)) del(model) self.assertEqual(o.clean(), DBG) From d36561cf565d6c6c6699c8e9122fafc4704989f2 Mon Sep 17 00:00:00 2001 From: brgix Date: Thu, 17 Jul 2025 05:37:37 -0400 Subject: [PATCH 18/49] Tweaks 'overlap' (more testing needed) --- src/osut/osut.py | 14 ++++----- tests/test_osut.py | 78 +++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 84 insertions(+), 8 deletions(-) diff --git a/src/osut/osut.py b/src/osut/osut.py index 5090892..be01c95 100644 --- a/src/osut/osut.py +++ b/src/osut/osut.py @@ -4049,8 +4049,6 @@ def overlap(p1=None, p2=None, flat=False) -> bool: delta = area1 + area2 - area if area > CN.TOL: - if round(area, 2) == round(area1, 2): return face - if round(area, 2) == round(area2, 2): return face if round(delta, 2) == 0: return face res = openstudio.intersect(a1, a2, CN.TOL) @@ -4582,6 +4580,7 @@ def medialBox(pts=None) -> openstudio.Point3dVector: if not pts: return bkp if isClockwise(pts): + pts = list(pts) pts.reverse() pts = p3Dv(pts) @@ -4614,8 +4613,9 @@ def medialBox(pts=None) -> openstudio.Point3dVector: box.append(mpoints[0]) box.append(mpoints[1]) box.append(plane.project(mpoints[1])) + box = list(nonCollinears(box)) - if box.size != 4: return bkp + if len(box) != 4: return bkp if isClockwise(box): box.reverse() @@ -4677,7 +4677,7 @@ def boundedBox(pts=None) -> openstudio.Point3dVector: for sg in segments(pts): m0 = midpoint(sg[0], sg[1]) - for seg in getSegments(pts): + for seg in segments(pts): p1 = seg[0] p2 = seg[1] if areSame(p1, sg[0]): continue @@ -4781,9 +4781,9 @@ def boundedBox(pts=None) -> openstudio.Point3dVector: aire = area box = out - if aire > CN.TOL: - if t: box = p3Dv(t * box) - return box + if aire > CN.TOL: + if t: box = p3Dv(t * box) + return box # PATH G : Medial box, triangulated approach. aire = 0 diff --git a/tests/test_osut.py b/tests/test_osut.py index 2a3b716..c15b607 100644 --- a/tests/test_osut.py +++ b/tests/test_osut.py @@ -2624,10 +2624,86 @@ def test23_fits_overlaps(self): pts = osut.nonCollinears(ceiling.vertices(), 3) box01 = osut.triadBox(pts) box11 = osut.boundedBox(ceiling) - print(o.logs()) self.assertTrue(osut.areSame(box01, box11)) self.assertTrue(osut.fits(box01, ceiling)) + pts = osut.nonCollinears(roof.vertices(), 3) + box02 = osut.medialBox(pts) + box12 = osut.boundedBox(roof) + self.assertTrue(osut.areSame(box02, box12)) + self.assertTrue(osut.fits(box02, roof)) + + box03 = osut.triadBox(pts) + self.assertFalse(osut.areSame(box03, box12)) + self.assertEqual(o.status(), 0) + + # For parallel surfaces, OSut's 'overlap' output is consistent + # regardless of the sequence of arguments. Here, floor and ceiling are + # mirrored - the former counterclockwise, the latter clockwise. The + # returned overlap conserves the vertex winding of the first surface. + self.assertTrue(osut.areParallel(floor, ceiling)) + olap1 = osut.overlap(floor, ceiling) + olap2 = osut.overlap(ceiling, floor) + self.assertTrue(osut.areSame(floor.vertices(), olap1)) + self.assertTrue(osut.areSame(ceiling.vertices(), olap2)) + + # When surfaces aren't parallel, 'overlap' remains somewhat consistent + # if both share a common edge. Here, the flat soffit shares an edge + # with the sloped roof. The projection of the soffit neatly fits onto + # the roof, yet the generated overlap will obviously be distorted with + # respect to the original soffit vertices. Nonetheless, the shared + # vertices/edge(s) would be preserved. + olap1 = osut.overlap(soffit, roof, True) + olap2 = osut.overlap(roof, soffit, True) + self.assertTrue(osut.areParallel(olap1, soffit)) + self.assertFalse(osut.areParallel(olap1, roof)) + self.assertTrue(osut.areParallel(olap2, roof)) + self.assertFalse(osut.areParallel(olap2, soffit)) + self.assertEqual(len(olap1), 4) + self.assertEqual(len(olap2), 4) + area1 = openstudio.getArea(olap1) + area2 = openstudio.getArea(olap2) + self.assertTrue(area1) + self.assertTrue(area2) + area1 = area1.get() + area2 = area2.get() + self.assertGreater(abs(area1 - area2), TOL) + pl1 = openstudio.Plane(olap1) + pl2 = openstudio.Plane(olap2) + n1 = pl1.outwardNormal() + n2 = pl2.outwardNormal() + dt1 = soffit.plane().outwardNormal().dot(n1) + dt2 = roof.plane().outwardNormal().dot(n2) + self.assertAlmostEqual(dt1, 1, places=2) + self.assertAlmostEqual(dt2, 1, places=2) + + # When surfaces are neither parallel nor share any edges (e.g. sloped roof + # vs horizontal floor), the generated overlap is more likely to hold extra + # vertices, depending on which surface it is cast onto. + olap1 = osut.overlap(floor, roof, True) + olap2 = osut.overlap(roof, floor, True) + self.assertTrue(osut.areParallel(olap1, floor)) + self.assertFalse(osut.areParallel(olap1, roof)) + self.assertTrue(osut.areParallel(olap2, roof)) + self.assertFalse(osut.areParallel(olap2, floor)) + self.assertEqual(len(olap1), 3) + self.assertEqual(len(olap2), 5) + area1 = openstudio.getArea(olap1) + area2 = openstudio.getArea(olap2) + self.assertTrue(area1) + self.assertTrue(area2) + area1 = area1.get() + area2 = area2.get() + self.assertGreater(area2 - area1, TOL) + pl1 = openstudio.Plane(olap1) + pl2 = openstudio.Plane(olap2) + n1 = pl1.outwardNormal() + n2 = pl2.outwardNormal() + dt1 = floor.plane().outwardNormal().dot(n1) + dt2 = roof.plane().outwardNormal().dot(n2) + self.assertAlmostEqual(dt1, 1, places=2) + self.assertAlmostEqual(dt2, 1, places=2) + del(model) self.assertEqual(o.clean(), DBG) From 623ebbb8c49f8264a0cf5a042dd63c0669157d09 Mon Sep 17 00:00:00 2001 From: brgix Date: Thu, 17 Jul 2025 07:07:38 -0400 Subject: [PATCH 19/49] Completes 'fits' & 'overlap' method testing --- src/osut/osut.py | 6 +- tests/test_osut.py | 243 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 247 insertions(+), 2 deletions(-) diff --git a/src/osut/osut.py b/src/osut/osut.py index be01c95..700eaa6 100644 --- a/src/osut/osut.py +++ b/src/osut/osut.py @@ -3083,11 +3083,13 @@ def triads(pts=None, co=False) -> openstudio.Point3dVectorVector: pts = uniques(pts) if len(pts) < 2: return vv - for i1, pts in enumerate(pts): + for i1, p1 in enumerate(pts): i2 = i1 + 1 if i2 == len(pts): i2 = 0 + i3 = i2 + 1 if i3 == len(pts): i3 = 0 + p2 = pts[i2] p3 = pts[i3] @@ -4128,7 +4130,7 @@ def cast(p1=None, p2=None, ray=None) -> openstudio.Point3dVector: for pt in p1: length = n.dot(pt - p0) / n.dot(ray.reverseVector()) - face.append(pt) + scalar(ray, length) + face.append(pt + scalar(ray, length)) return face diff --git a/tests/test_osut.py b/tests/test_osut.py index c15b607..75dac3f 100644 --- a/tests/test_osut.py +++ b/tests/test_osut.py @@ -2704,6 +2704,249 @@ def test23_fits_overlaps(self): self.assertAlmostEqual(dt1, 1, places=2) self.assertAlmostEqual(dt2, 1, places=2) + # Alternative: first 'cast' vertically one polygon onto the other. + pl1 = openstudio.Plane(ceiling.vertices()) + pl2 = openstudio.Plane(roof.vertices()) + up = openstudio.Point3d(0, 0, 1) - openstudio.Point3d(0, 0, 0) + down = openstudio.Point3d(0, 0,-1) - openstudio.Point3d(0, 0, 0) + cast00 = osut.cast(roof, ceiling, down) + cast01 = osut.cast(roof, ceiling, up) + cast02 = osut.cast(ceiling, roof, up) + self.assertTrue(osut.areParallel(cast00, ceiling)) + self.assertTrue(osut.areParallel(cast01, ceiling)) + self.assertTrue(osut.areParallel(cast02, roof)) + self.assertFalse(osut.areParallel(cast00, roof)) + self.assertFalse(osut.areParallel(cast01, roof)) + self.assertFalse(osut.areParallel(cast02, ceiling)) + + # As the cast ray is vertical, only the Z-axis coordinate changes. + for i, pt in enumerate(cast00): + self.assertTrue(pl1.pointOnPlane(pt)) + self.assertAlmostEqual(pt.x(), roof.vertices()[i].x(), places=2) + self.assertAlmostEqual(pt.y(), roof.vertices()[i].y(), places=2) + + # The direction of the cast ray doesn't matter (e.g. up or down). + for i, pt in enumerate(cast01): + self.assertTrue(pl1.pointOnPlane(pt)) + self.assertAlmostEqual(pt.x(), cast00[i].x(), places=2) + self.assertAlmostEqual(pt.y(), cast00[i].y(), places=2) + + # The sequence of arguments matters: 1st polygon is cast onto 2nd. + for i, pt in enumerate(cast02): + self.assertTrue(pl2.pointOnPlane(pt)) + self.assertAlmostEqual(pt.x(), ceiling.vertices()[i].x()) + self.assertAlmostEqual(pt.y(), ceiling.vertices()[i].y()) + + # Overlap between roof and vertically-cast ceiling onto roof plane. + olap02 = osut.overlap(roof, cast02) + self.assertEqual(len(olap02), 3) # not 5 + self.assertTrue(osut.fits(olap02, roof)) + + for pt in olap02: self.assertTrue(pl2.pointOnPlane(pt)) + + vtx1 = openstudio.Point3dVector() + vtx1.append(openstudio.Point3d(17.69, 0.00, 0)) + vtx1.append(openstudio.Point3d(13.46, 4.46, 0)) + vtx1.append(openstudio.Point3d( 4.23, 4.46, 0)) + vtx1.append(openstudio.Point3d( 0.00, 0.00, 0)) + + vtx2 = openstudio.Point3dVector() + vtx2.append(openstudio.Point3d( 8.85, 0.00, 0)) + vtx2.append(openstudio.Point3d( 8.85, 4.46, 0)) + vtx2.append(openstudio.Point3d( 4.23, 4.46, 0)) + vtx2.append(openstudio.Point3d( 4.23, 0.00, 0)) + + self.assertTrue(osut.isPointAlongSegment(vtx2[1], [vtx1[1], vtx1[2]])) + self.assertTrue(osut.isPointAlongSegments(vtx2[1], vtx1)) + self.assertTrue(osut.isPointWithinPolygon(vtx2[1], vtx1)) + self.assertTrue(osut.fits(vtx2, vtx1)) + + # Bounded box test. + cast03 = osut.cast(ceiling, south, down) + self.assertTrue(osut.isRectangular(cast03)) + olap03 = osut.overlap(south, cast03) + self.assertTrue(osut.areParallel(south, olap03)) + self.assertFalse(osut.isRectangular(olap03)) + box = osut.boundedBox(olap03) + self.assertTrue(osut.isRectangular(box)) + self.assertTrue(osut.areParallel(olap03, box)) + + area1 = openstudio.getArea(olap03) + area2 = openstudio.getArea(box) + self.assertTrue(area1) + self.assertTrue(area2) + area1 = area1.get() + area2 = area2.get() + self.assertEqual(int(100 * area2 / area1), 68) # % + self.assertEqual(o.status(), 0) + + del(model) + + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + # Testing more complex cases, e.g. triangular windows, irregular 4-side + # windows, rough opening edges overlapping parent surface edges. These + # tests were initially part of the TBD Tests repository: + # + # github.com/rd2/tbd_tests + # + # ... yet have been upgraded and are now tested here. + model = openstudio.model.Model() + space = openstudio.model.Space(model) + space.setName("Space") + + # Windows are SimpleGlazing constructions. + fen = openstudio.model.Construction(model) + glazing = openstudio.model.SimpleGlazing(model) + layers = openstudio.model.MaterialVector() + fen.setName("FD fen") + glazing.setName("FD glazing") + self.assertTrue(glazing.setUFactor(2.0)) + layers.append(glazing) + self.assertTrue(fen.setLayers(layers)) + + # Frame & Divider object. + w000 = 0.000 + w200 = 0.200 # 0mm to 200mm (wide!) around glazing + fd = openstudio.model.WindowPropertyFrameAndDivider(model) + fd.setName("FD") + self.assertTrue(fd.setFrameConductance(0.500)) + self.assertTrue(fd.isFrameWidthDefaulted()) + self.assertAlmostEqual(fd.frameWidth(), w000, places=2) + + # A square base wall surface: + v0 = openstudio.Point3dVector() + v0.append(openstudio.Point3d( 0.00, 0.00, 10.00)) + v0.append(openstudio.Point3d( 0.00, 0.00, 0.00)) + v0.append(openstudio.Point3d(10.00, 0.00, 0.00)) + v0.append(openstudio.Point3d(10.00, 0.00, 10.00)) + + # A first triangular window: + v1 = openstudio.Point3dVector() + v1.append(openstudio.Point3d( 2.00, 0.00, 8.00)) + v1.append(openstudio.Point3d( 1.00, 0.00, 6.00)) + v1.append(openstudio.Point3d( 4.00, 0.00, 9.00)) + + # A larger, irregular window: + v2 = openstudio.Point3dVector() + v2.append(openstudio.Point3d( 7.00, 0.00, 4.00)) + v2.append(openstudio.Point3d( 4.00, 0.00, 1.00)) + v2.append(openstudio.Point3d( 8.00, 0.00, 2.00)) + v2.append(openstudio.Point3d( 9.00, 0.00, 3.00)) + + # A final triangular window, near the wall's upper right corner: + v3 = openstudio.Point3dVector() + v3.append(openstudio.Point3d( 9.00, 0.00, 9.80)) + v3.append(openstudio.Point3d( 9.80, 0.00, 9.00)) + v3.append(openstudio.Point3d( 9.80, 0.00, 9.80)) + + w0 = openstudio.model.Surface(v0, model) + w1 = openstudio.model.SubSurface(v1, model) + w2 = openstudio.model.SubSurface(v2, model) + w3 = openstudio.model.SubSurface(v3, model) + w0.setName("w0") + w1.setName("w1") + w2.setName("w2") + w3.setName("w3") + self.assertTrue(w0.setSpace(space)) + sub_gross = 0 + + for w in [w1, w2, w3]: + self.assertTrue(w.setSubSurfaceType("FixedWindow")) + self.assertTrue(w.setSurface(w0)) + self.assertTrue(w.setConstruction(fen)) + self.assertTrue(w.uFactor()) + self.assertAlmostEqual(w.uFactor().get(), 2.0, places=1) + self.assertTrue(w.allowWindowPropertyFrameAndDivider()) + self.assertTrue(w.setWindowPropertyFrameAndDivider(fd)) + width = w.windowPropertyFrameAndDivider().get().frameWidth() + self.assertAlmostEqual(width, w000, places=2) + + sub_gross += w.grossArea() + + self.assertAlmostEqual(w1.grossArea(), 1.50, places=2) + self.assertAlmostEqual(w2.grossArea(), 6.00, places=2) + self.assertAlmostEqual(w3.grossArea(), 0.32, places=2) + self.assertAlmostEqual(w0.grossArea(), 100.00, places=2) + self.assertAlmostEqual(w1.netArea(), w1.grossArea(), places=2) + self.assertAlmostEqual(w2.netArea(), w2.grossArea(), places=2) + self.assertAlmostEqual(w3.netArea(), w3.grossArea(), places=2) + self.assertAlmostEqual(w0.netArea(), w0.grossArea()-sub_gross, places=2) + + # Applying 2 sets of alterations: + # - WITHOUT, then WITH Frame & Dividers (F&D) + # - 3 successive 20° rotations around: + angle = math.pi / 9 + origin = openstudio.Point3d(0, 0, 0) + east = openstudio.Point3d(1, 0, 0) - origin + up = openstudio.Point3d(0, 0, 1) - origin + north = openstudio.Point3d(0, 1, 0) - origin + + for i in range(4): # successive rotations + if i != 0: + if i == 1: r = openstudio.createRotation(origin, east, angle) + if i == 2: r = openstudio.createRotation(origin, up, angle) + if i == 3: r = openstudio.createRotation(origin, north, angle) + self.assertTrue(w0.setVertices(r.inverse() * w0.vertices())) + self.assertTrue(w1.setVertices(r.inverse() * w1.vertices())) + self.assertTrue(w2.setVertices(r.inverse() * w2.vertices())) + self.assertTrue(w3.setVertices(r.inverse() * w3.vertices())) + + for j in range(2): # F&D + if j == 0: + wx = w000 + if i != 0: fd.resetFrameWidth() + else: + wx = w200 + self.assertTrue(fd.setFrameWidth(wx)) + + for w in [w1, w2, w3]: + wfd = w.windowPropertyFrameAndDivider().get() + width = wfd.frameWidth() + self.assertAlmostEqual(width, wx, places=2) + + # F&D widths offset window vertices. + w1o = osut.offset(w1.vertices(), wx, 300) + w2o = osut.offset(w2.vertices(), wx, 300) + w3o = osut.offset(w3.vertices(), wx, 300) + + w1o_m2 = openstudio.getArea(w1o) + w2o_m2 = openstudio.getArea(w2o) + w3o_m2 = openstudio.getArea(w3o) + self.assertTrue(w1o_m2) + self.assertTrue(w2o_m2) + self.assertTrue(w3o_m2) + w1o_m2 = w1o_m2.get() + w2o_m2 = w2o_m2.get() + w3o_m2 = w3o_m2.get() + + if j == 0: + # w1 == 1.50m2; w2 == 6.00 m2; w3 == 0.32m2 + self.assertAlmostEqual(w1o_m2, w1.grossArea(), places=2) + self.assertAlmostEqual(w2o_m2, w2.grossArea(), places=2) + self.assertAlmostEqual(w3o_m2, w3.grossArea(), places=2) + else: + self.assertAlmostEqual(w1o_m2, 3.75, places=2) + self.assertAlmostEqual(w2o_m2, 8.64, places=2) + self.assertAlmostEqual(w3o_m2, 1.10, places=2) + + # All windows entirely fit within the wall (without F&D). + for w in [w1, w2, w3]: self.assertTrue(osut.fits(w, w0, True)) + + # All windows fit within the wall (with F&D). + for w in [w1o, w2o]: self.assertTrue(osut.fits(w, w0)) + + # If F&D frame width == 200mm, w3o aligns along the wall top & + # side, so not entirely within wall polygon. + self.assertTrue(osut.fits(w3, w0, True)) + self.assertTrue(osut.fits(w3o, w0)) + if j == 0: self.assertTrue(osut.fits(w3o, w0, True)) + if j != 0: self.assertFalse(osut.fits(w3o, w0, True)) + + # None of the windows conflict with each other. + self.assertFalse(osut.overlapping(w1o, w2o)) + self.assertFalse(osut.overlapping(w1o, w3o)) + self.assertFalse(osut.overlapping(w2o, w3o)) + del(model) self.assertEqual(o.clean(), DBG) From 33600d1d81adb31329e1759ecb27af5fef26866e Mon Sep 17 00:00:00 2001 From: brgix Date: Thu, 17 Jul 2025 09:29:51 -0400 Subject: [PATCH 20/49] Completes segments, triads and orientation tests --- src/osut/osut.py | 145 +++++++++++++++++++++++++++++++++++++++++++-- tests/test_osut.py | 96 ++++++++++++++++++++++++++++++ 2 files changed, 236 insertions(+), 5 deletions(-) diff --git a/src/osut/osut.py b/src/osut/osut.py index 700eaa6..b862ee7 100644 --- a/src/osut/osut.py +++ b/src/osut/osut.py @@ -1299,6 +1299,141 @@ def isFenestrated(s=None) -> bool: return True +# ---- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- ---- # +# ---- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- ---- # +# This next set of utilities (~850 lines) help distinguish spaces that are +# directly vs indirectly CONDITIONED, vs SEMIHEATED. The solution here +# relies as much as possible on space conditioning categories found in +# standards like ASHRAE 90.1 and energy codes like the Canadian NECBs. +# +# Both documents share many similarities, regardless of nomenclature. There +# are however noticeable differences between approaches on how a space is +# tagged as falling into one of the aforementioned categories. First, an +# overview of 90.1 requirements, with some minor edits for brevity/emphasis: +# +# www.pnnl.gov/main/publications/external/technical_reports/PNNL-26917.pdf +# +# 3.2.1. General Information - SPACE CONDITIONING CATEGORY +# +# - CONDITIONED space: an ENCLOSED space that has a heating and/or +# cooling system of sufficient size to maintain temperatures suitable +# for HUMAN COMFORT: +# - COOLED: cooled by a system >= 10 W/m2 +# - HEATED: heated by a system, e.g. >= 50 W/m2 in Climate Zone CZ-7 +# - INDIRECTLY: heated or cooled via adjacent space(s) provided: +# - UA of adjacent surfaces > UA of other surfaces +# or +# - intentional air transfer from HEATED/COOLED space > 3 ACH +# +# ... includes plenums, atria, etc. +# +# - SEMIHEATED space: an ENCLOSED space that has a heating system +# >= 10 W/m2, yet NOT a CONDITIONED space (see above). +# +# - 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 +# mechanical ventilation are considered UNENCLOSED spaces. +# +# 2.3.3 Modeling Requirements: surfaces adjacent to UNENCLOSED spaces +# shall be treated as exterior surfaces. All other UNENCLOSED surfaces +# are to be modeled as is in both proposed and baseline models. For +# instance, modeled fenestration in UNENCLOSED spaces would not be +# factored in WWR calculations. +# +# +# Related NECB definitions and concepts, starting with CONDITIONED space: +# +# "[...] the temperature of which is controlled to limit variation in +# response to the exterior ambient temperature by the provision, either +# DIRECTLY or INDIRECTLY, of heating or cooling [...]". Although criteria +# differ (e.g., not sizing-based), the general idea is sufficiently similar +# to ASHRAE 90.1 (e.g. heating and/or cooling based, no distinction for +# INDIRECTLY conditioned spaces like plenums). +# +# SEMIHEATED spaces are described in the NECB (yet not a defined term). The +# distinction is also based on desired/intended design space setpoint +# temperatures (here 15°C) - not system sizing criteria. No further treatment +# is implemented here to distinguish SEMIHEATED from CONDITIONED spaces; +# notwithstanding the AdditionalProperties tag (described further in this +# section), it is up to users to determine if a CONDITIONED space is +# indeed SEMIHEATED or not (e.g. based on MIN/MAX setpoints). +# +# The single NECB criterion distinguishing UNCONDITIONED ENCLOSED spaces +# (such as vestibules) from UNENCLOSED spaces (such as attics) remains the +# intention to ventilate - or rather to what degree. Regardless, the methods +# here are designed to process both classifications in the same way, namely +# by focusing on adjacent surfaces to CONDITIONED (or SEMIHEATED) spaces as +# part of the building envelope. + +# In light of the above, OSut methods here are designed without a priori +# knowledge of explicit system sizing choices or access to iterative +# autosizing processes. As discussed in greater detail below, methods here +# are developed to rely on zoning and/or "intended" setpoint temperatures. +# In addition, OSut methods here cannot distinguish between UNCONDITIONED vs +# UNENCLOSED spaces from OpenStudio geometry alone. They are henceforth +# considered synonymous. +# +# For an OpenStudio model in an incomplete or preliminary state, e.g. holding +# fully-formed ENCLOSED spaces WITHOUT thermal zoning information or setpoint +# temperatures (early design stage assessments of form, porosity or +# envelope), OpenStudio spaces are considered CONDITIONED by default. This +# default behaviour may be reset based on the (Space) AdditionalProperties +# "space_conditioning_category" key (4x possible values), which is relied +# upon by OpenStudio-Standards: +# +# github.com/NREL/openstudio-standards/blob/ +# d2b5e28928e712cb3f137ab5c1ad6d8889ca02b7/lib/openstudio-standards/ +# standards/Standards.Space.rb#L1604C5-L1605C1 +# +# OpenStudio-Standards recognizes 4x possible value strings: +# - "NonResConditioned" +# - "ResConditioned" +# - "Semiheated" +# - "Unconditioned" +# +# OSut maintains existing "space_conditioning_category" key/value pairs +# intact. Based on these, OSut methods may return related outputs: +# +# "space_conditioning_category" | OSut status | heating °C | cooling °C +# ------------------------------- ------------- ---------- ---------- +# - "NonResConditioned" CONDITIONED 21.0 24.0 +# - "ResConditioned" CONDITIONED 21.0 24.0 +# - "Semiheated" SEMIHEATED 15.0 NA +# - "Unconditioned" UNCONDITIONED NA NA +# +# OSut also looks up another (Space) AdditionalProperties 'key', +# "indirectlyconditioned" to flag plenum or occupied spaces indirectly +# conditioned with transfer air only. The only accepted 'value' for an +# "indirectlyconditioned" 'key' is the name (string) of another (linked) +# space, e.g.: +# +# "indirectlyconditioned" space | linked space, e.g. "core_space" +# ------------------------------- --------------------------------------- +# return air plenum occupied space below +# supply air plenum occupied space above +# dead air space (not a plenum) nearby occupied space +# +# OSut doesn't validate whether the "indirectlyconditioned" space is actually +# adjacent to its linked space. It nonetheless relies on the latter's +# conditioning category (e.g. CONDITIONED, SEMIHEATED) to determine +# anticipated ambient temperatures in the former. For instance, an +# "indirectlyconditioned"-tagged return air plenum linked to a SEMIHEATED +# space is considered as free-floating in terms of cooling, and unlikely to +# have ambient conditions below 15°C under heating (winter) design +# conditions. OSut will associate this plenum to a 15°C heating setpoint +# temperature. If the SEMIHEATED space instead has a heating setpoint +# temperature of 7°C, then OSut will associate a 7°C heating setpoint to this +# plenum. +# +# Even with a (more developed) OpenStudio model holding valid space/zone +# setpoint temperatures, OSut gives priority to these AdditionalProperties. +# For instance, a CONDITIONED space can be considered INDIRECTLYCONDITIONED, +# even if its zone thermostat has a valid heating and/or cooling setpoint. +# This is in sync with OpenStudio-Standards' method +# "space_conditioning_category()". + def hasAirLoopsHVAC(model=None) -> bool: """Validates if model has zones with HVAC air loops. @@ -1668,14 +1803,14 @@ def maxHeatScheduledSetpoint(zone=None) -> dict: def hasHeatingTemperatureSetpoints(model=None): - """Confirms if model has zones with valid heating temperature setpoints. + """Confirms if model has zones with valid heating setpoint temperature. Args: model (openstudio.model.Model): An OpenStudio model. Returns: - bool: Whether model holds valid heating temperature setpoints. + bool: Whether model holds valid heating setpoint temperatures. False: If invalid inputs (see logs). """ mth = "osut.hasHeatingTemperatureSetpoints" @@ -1850,14 +1985,14 @@ def minCoolScheduledSetpoint(zone=None): def hasCoolingTemperatureSetpoints(model=None): - """Confirms if model has zones with valid cooling temperature setpoints. + """Confirms if model has zones with valid cooling setpoint temperatures. Args: model (openstudio.model.Model): An OpenStudio model. Returns: - bool: Whether model holds valid cooling temperature setpoints. + bool: Whether model holds valid cooling setpoint temperatures. False: If invalid inputs (see logs). """ mth = "osut.hasCoolingTemperatureSetpoints" @@ -3089,7 +3224,7 @@ def triads(pts=None, co=False) -> openstudio.Point3dVectorVector: i3 = i2 + 1 if i3 == len(pts): i3 = 0 - + p2 = pts[i2] p3 = pts[i3] diff --git a/tests/test_osut.py b/tests/test_osut.py index 75dac3f..a69bd37 100644 --- a/tests/test_osut.py +++ b/tests/test_osut.py @@ -3098,6 +3098,102 @@ def test25_segments_triads_orientation(self): pt = osut.lineIntersection([p0, p2], [p6, p4]) self.assertTrue(osut.areSame(pt, p5)) + # Point ENTIRELY within (vs outside) a polygon. + self.assertFalse(osut.isPointWithinPolygon(p0, [p0, p1, p2, p3], True)) + self.assertFalse(osut.isPointWithinPolygon(p1, [p0, p1, p2, p3], True)) + self.assertFalse(osut.isPointWithinPolygon(p2, [p0, p1, p2, p3], True)) + self.assertFalse(osut.isPointWithinPolygon(p3, [p0, p1, p2, p3], True)) + self.assertFalse(osut.isPointWithinPolygon(p4, [p0, p1, p2, p3])) + self.assertTrue(osut.isPointWithinPolygon(p5, [p0, p1, p2, p3])) + self.assertFalse(osut.isPointWithinPolygon(p6, [p0, p1, p2, p3])) + self.assertTrue(osut.isPointWithinPolygon(p7, [p0, p1, p2, p3])) + self.assertEqual(o.status(), 0) + + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + # Test invalid plane. + vtx = openstudio.Point3dVector() + vtx.append(openstudio.Point3d(20, 0, 10)) + vtx.append(openstudio.Point3d( 0, 0, 10)) + vtx.append(openstudio.Point3d( 0, 0, 0)) + vtx.append(openstudio.Point3d(20, 1, 0)) + + self.assertEqual(len(osut.poly(vtx)), 0) + self.assertTrue(o.is_error()) + self.assertEqual(len(o.logs()), 1) + self.assertTrue("Empty 'plane'" in o.logs()[0]["message"]) + self.assertEqual(o.clean(), DBG) + + # Self-intersecting polygon. If reactivated, OpenStudio logs to stdout: + # [utilities.Transformation] + # <1> Cannot compute outward normal for vertices + # vtx = openstudio.Point3dVector() + # vtx.append(openstudio.Point3d(20, 0, 10)) + # vtx.append(openstudio.Point3d( 0, 0, 10)) + # vtx.append(openstudio.Point3d(20, 0, 0)) + # vtx.append(openstudio.Point3d( 0, 0, 0)) + # + # Original polygon remains unaltered. + # self.assertEqual(len(osut.poly(vtx)), 4) + # self.assertEqual(o.status(), 0) + # self.assertEqual(o.clean(), DBG) + + # Regular polygon, counterclockwise yet not UpperLeftCorner (ULC). + vtx = openstudio.Point3dVector() + vtx.append(openstudio.Point3d(20, 0, 10)) + vtx.append(openstudio.Point3d( 0, 0, 10)) + vtx.append(openstudio.Point3d( 0, 0, 0)) + + sgs = osut.segments(vtx) + self.assertTrue(isinstance(sgs, openstudio.Point3dVectorVector)) + self.assertEqual(len(sgs), 3) + + for i, sg in enumerate(sgs): + if not osut.shareXYZ(sg, "x", sg[0].x()): + vplane = osut.verticalPlane(sg[0], sg[1]) + self.assertTrue(isinstance(vplane, openstudio.Plane)) + + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + # Test when alignFace switches solution when surfaces are nearly flat, + # i.e. when dot product of surface normal vs zenith > 0.99. + # (see openstudio.Transformation.alignFace) + origin = openstudio.Point3d(0,0,0) + originZ = openstudio.Point3d(0,0,1) + zenith = originZ - origin + + # 1st surface, nearly horizontal. + vtx = openstudio.Point3dVector() + vtx.append(openstudio.Point3d( 2,10, 0.0)) + vtx.append(openstudio.Point3d( 6, 4, 0.0)) + vtx.append(openstudio.Point3d( 8, 8, 0.5)) + normal = openstudio.getOutwardNormal(vtx).get() + self.assertGreater(abs(zenith.dot(normal)), 0.99) + self.assertTrue(osut.facingUp(vtx)) + + aligned = list(osut.poly(vtx, False, False, False, True, "ulc")) + matches = [] + + for pt in aligned: + if osut.areSame(pt, origin): matches.append(pt) + + self.assertEqual(len(matches), 0) + + # 2nd surface (nearly identical, yet too slanted to be flat. + vtx = openstudio.Point3dVector() + vtx.append(openstudio.Point3d( 2,10, 0.0)) + vtx.append(openstudio.Point3d( 6, 4, 0.0)) + vtx.append(openstudio.Point3d( 8, 8, 0.6)) + normal = openstudio.getOutwardNormal(vtx).get() + self.assertLess(abs(zenith.dot(normal)), 0.99) + self.assertFalse(osut.facingUp(vtx)) + + aligned = list(osut.poly(vtx, False, False, False, True, "ulc")) + matches = [] + + for pt in aligned: + if osut.areSame(pt, origin): matches.append(pt) + + self.assertEqual(len(matches), 1) + def test26_ulc_blc(self): o = osut.oslg self.assertEqual(o.status(), 0) From 9d44c8a120886c1c59c09b2ad6bd40a04d8007be Mon Sep 17 00:00:00 2001 From: brgix Date: Thu, 17 Jul 2025 15:47:47 -0400 Subject: [PATCH 21/49] Initial implementation of 'realignedFace' (untested) --- src/osut/osut.py | 130 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) diff --git a/src/osut/osut.py b/src/osut/osut.py index b862ee7..402b541 100644 --- a/src/osut/osut.py +++ b/src/osut/osut.py @@ -4959,6 +4959,136 @@ def boundedBox(pts=None) -> openstudio.Point3dVector: return box +def realignedFace(pts=None, force=False) -> dict: + """Generates re-'aligned' polygon vertices with respect to main axis of + symmetry of its largest 'bounded box'. Input polygon vertex Z-axis values + must equal 0, and be counterclockwise. First, cloned polygon vertices are + rotated so the longest axis of symmetry of its bounded box lies parallel to + the X-axis (see returned key "o": midpoint of the narrow side of the bounded + box, nearest to grid origin [0,0,0]). If the axis of symmetry of the bounded + box is already parallel to the X-axis, then the rotation step is skipped + (unless 'force' is True). Whether rotated or not, polygon vertices are then + translated as to ensure one or more vertices are aligned along the X-axis + and one or more vertices are aligned along the Y-axis (no vertices with + negative X or Y coordinate values). To unalign the returned set of vertices + (or its bounded box, or its bounding box), first inverse the translation + transformation, then inverse the rotation transformation. If failure (e.g. + invalid inputs), the returned dict values are set to None. + + Args: + pts (openstudio.Point3dVector): + A set of OpenStudio 3D points. + force (bool): + Whether to force rotation for aligned (yet narrow) boxes. + + Returns: + dict: + - "set" (openstudio.Point3dVector): realigned (cloned) polygon vertices + - "box" (openstudio.Point3dVector): its bounded box (wrt to "set") + - "bbox" (openstudio.Point3dVector): its bounding box + - "t" (openstudio.Transformation): its translation transformation + - "r" (openstudio.Transformation): its rotation transformation + - "o" (openstudio.Point3d): origin coordinates of its axis of rotation + + """ + mth = "osut.realignedFace" + out = dict(set=None, box=None, bbox=None, t=None, r=None, o=None) + pts = poly(pts, False, True) + if not pts: return out + + if not shareXYZ(pts, "z"): + return oslg.invalid("aligned plane", mth, 1, CN.DBG, out) + + if isClockwise(pts): + return oslg.invalid("clockwise pts", mth, 1, CN.DBG, out) + + # Optionally force rotation so bounded box ends up wider than taller. + # Strongly suggested for flat surfaces like roofs (see 'sloped?'). + try: + force = bool(force) + except: + oslg.log(CN.DBG, "Ignoring force input (%s)" % mth) + force = False + + o = openstudio.Point3d(0, 0, 0) + w = width(pts) + h = height(pts) + d = h if h > w else w + + sgs = {} + box = boundedBox(pts) + + if not box: + return oslg.invalid("bounded box", mth, 0, CN.DBG, out) + + segs = segments(box) + + if not segs: + return oslg.invalid("bounded box segments", mth, 0, CN.DBG, out) + + # Deterministic ID of box rotation/translation 'origin'. + for idx, sg in enumerate(segs): + sgs[sg] = {} + sgs[sg]["idx"] = idx + sgs[sg]["mid"] = midpoint(sg[0], sg[1]) + sgs[sg]["l" ] = (sg[1] - sg[0]).length() + sgs[sg]["mo" ] = (sgs[sg]["mid"] - o).length() + + if isSquare(box): + sgs = dict(sorted(sgs.items(), key=lambda item: item[1]["mo"])[:2]) + else: + sgs = dict(sorted(sgs.items(), key=lambda item: item[1]["l" ])[:2]) + sgs = dict(sorted(sgs.items(), key=lambda item: item[1]["mo"])[:2]) + + sg0 = sgs.values[0] + sg1 = sgs.values[1] + + i = sg0["idx"] + + if round(sg0["mo"], 2) == round(sg1["mo"], 2): + if round(sg1["mid"].y(), 2) < round(sg0["mid"].y(), 2): + i = sg1["idx"] + + k = i+2 if i+2 < len(segs) else i-2 + + origin = midpoint(segs[i][0], segs[i][1]) + terminal = midpoint(segs[k][0], segs[k][1]) + + seg = terminal - origin + right = openstudio.Point3d(origin.x() + d, origin.y() , 0) - origin + north = openstudio.Point3d(origin.x(), origin.y() + d, 0) - origin + axis = openstudio.Point3d(origin.x(), origin.y() , d) - origin + angle = openstudio.getAngle(right, seg) + + if north.dot(seg) < 0: angle = -angle + + # Skip rotation if bounded box is already aligned along XY grid (albeit + # 'narrow'), i.e. if the angle is 90°. + if round(angle, 3) == round(math.pi/2, 3): + if force is False: angle = 0 + + r = openstudio.createRotation(origin, axis, angle) + pts = p3Dv(r.inverse() * pts) + box = p3Dv(r.inverse() * box) + dX = min([pt.x() for pt in pts]) + dY = min([pt.y() for pt in pts]) + xy = openstudio.Point3d(origin.x() + dX, origin.y() + dY, 0) + o2 = xy - origin + t = openstudio.createTranslation(o2) + set = p3Dv(t.inverse() * pts) + box = p3Dv(t.inverse() * box) + bbox = outline([set]) + + out["set" ] = blc(set) + out["box" ] = blc(box) + out["bbox"] = blc(bbox) + out["t" ] = t + out["r" ] = r + out["o" ] = origin + + return out + + def facets(spaces=[], boundary="all", type="all", sides=[]) -> list: """Returns an array of OpenStudio space surfaces or subsurfaces that match criteria, e.g. exterior, north-east facing walls in hotel "lobby". Note From 9a4c20ee3f538c94263bf51b0d70e14db284d5b5 Mon Sep 17 00:00:00 2001 From: brgix Date: Fri, 18 Jul 2025 05:54:31 -0400 Subject: [PATCH 22/49] Completes 'poly' attributes testing --- tests/test_osut.py | 217 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 216 insertions(+), 1 deletion(-) diff --git a/tests/test_osut.py b/tests/test_osut.py index a69bd37..6365601 100644 --- a/tests/test_osut.py +++ b/tests/test_osut.py @@ -3368,7 +3368,222 @@ def test26_ulc_blc(self): # [70, 45, 0] # [ 0, 45, 0] - # def test27_polygon_attributes(self): + def test27_polygon_attributes(self): + o = osut.oslg + self.assertEqual(o.status(), 0) + self.assertEqual(o.reset(INF), INF) + self.assertEqual(o.level(), INF) + self.assertEqual(o.status(), 0) + + # 2x points (not a polygon). + vtx = openstudio.Point3dVector() + vtx.append(openstudio.Point3d( 0, 0,10)) + vtx.append(openstudio.Point3d( 0, 0,10)) + + v = osut.poly(vtx) + self.assertTrue(isinstance(v, openstudio.Point3dVector)) + self.assertFalse(v) + self.assertTrue(o.is_error()) + self.assertEqual(len(o.logs()), 1) + self.assertTrue("non-collinears < 3" in o.logs()[0]["message"]) + self.assertEqual(o.clean(), INF) + + # 3x non-unique points (not a polygon). + vtx = openstudio.Point3dVector() + vtx.append(openstudio.Point3d( 0, 0,10)) + vtx.append(openstudio.Point3d( 0, 0,10)) + vtx.append(openstudio.Point3d( 0, 0,10)) + + v = osut.poly(vtx) + self.assertTrue(isinstance(v, openstudio.Point3dVector)) + self.assertFalse(v) + self.assertTrue(o.is_error()) + self.assertEqual(len(o.logs()), 1) + self.assertTrue("non-collinears < 3" in o.logs()[0]["message"]) + self.assertEqual(o.clean(), INF) + + # 4th non-planar point (not a polygon). + vtx = openstudio.Point3dVector() + vtx.append(openstudio.Point3d( 0, 0,10)) + vtx.append(openstudio.Point3d( 0, 0, 0)) + vtx.append(openstudio.Point3d(10, 0,10)) + vtx.append(openstudio.Point3d( 0,10,10)) + + v = osut.poly(vtx) + self.assertTrue(isinstance(v, openstudio.Point3dVector)) + self.assertFalse(v) + self.assertTrue(o.is_error()) + self.assertEqual(len(o.logs()), 1) + self.assertTrue("plane" in o.logs()[0]["message"]) + self.assertEqual(o.clean(), INF) + + # 3x unique points (a valid polygon). + vtx = openstudio.Point3dVector() + vtx.append(openstudio.Point3d( 0, 0,10)) + vtx.append(openstudio.Point3d( 0, 0, 0)) + vtx.append(openstudio.Point3d(10, 0, 0)) + + v = osut.poly(vtx) + self.assertTrue(isinstance(v, openstudio.Point3dVector)) + self.assertEqual(len(v), 3) + self.assertEqual(o.status(), 0) + + # 4th collinear point (collinear permissive). + vtx.append(openstudio.Point3d(20, 0, 0)) + v = osut.poly(vtx) + self.assertTrue(isinstance(v, openstudio.Point3dVector)) + self.assertEqual(len(v), 4) + self.assertEqual(o.status(), 0) + + # Intersecting points, e.g. a 'bowtie' (not a valid Openstudio polygon). + vtx = openstudio.Point3dVector() + vtx.append(openstudio.Point3d( 0, 0,10)) + vtx.append(openstudio.Point3d( 0, 0, 0)) + vtx.append(openstudio.Point3d(10, 0,10)) + vtx.append(openstudio.Point3d( 0,10, 0)) + + v = osut.poly(vtx) + self.assertTrue(isinstance(v, openstudio.Point3dVector)) + self.assertFalse(v) + self.assertTrue(o.is_error()) + self.assertEqual(len(o.logs()), 1) + self.assertTrue("Empty 'plane' (osut.poly)" in o.logs()[0]["message"]) + self.assertEqual(o.clean(), INF) + + # Ensure uniqueness & OpenStudio's counterclockwise ULC sequence. + vtx = openstudio.Point3dVector() + vtx.append(openstudio.Point3d( 0, 0,10)) + vtx.append(openstudio.Point3d( 0, 0, 0)) + vtx.append(openstudio.Point3d(10, 0, 0)) + vtx.append(openstudio.Point3d(10, 0, 0)) + + v = osut.poly(vtx, False, True, False, False, "ulc") + self.assertTrue(isinstance(v, openstudio.Point3dVector)) + self.assertEqual(len(v), 3) + self.assertTrue(osut.areSame(vtx[0], v[0])) + self.assertTrue(osut.areSame(vtx[1], v[1])) + self.assertTrue(osut.areSame(vtx[2], v[2])) + self.assertEqual(o.status(), 0) + + # Ensure strict non-collinearity (ULC). + vtx = openstudio.Point3dVector() + vtx.append(openstudio.Point3d( 0, 0,10)) + vtx.append(openstudio.Point3d( 0, 0, 0)) + vtx.append(openstudio.Point3d(10, 0, 0)) + vtx.append(openstudio.Point3d(20, 0, 0)) + + v = osut.poly(vtx, False, False, True, False, "ulc") + self.assertTrue(isinstance(v, openstudio.Point3dVector)) + self.assertEqual(len(v), 3) + self.assertTrue(osut.areSame(vtx[0], v[0])) + self.assertTrue(osut.areSame(vtx[1], v[1])) + self.assertTrue(osut.areSame(vtx[3], v[2])) + self.assertEqual(o.status(), 0) + + # Ensuring strict non-collinearity also ensures uniqueness (ULC). + vtx = openstudio.Point3dVector() + vtx.append(openstudio.Point3d( 0, 0,10)) + vtx.append(openstudio.Point3d( 0, 0, 0)) + vtx.append(openstudio.Point3d( 0, 0, 0)) + vtx.append(openstudio.Point3d(10, 0, 0)) + vtx.append(openstudio.Point3d(20, 0, 0)) + + v = osut.poly(vtx, False, False, True, False, "ulc") + self.assertTrue(isinstance(v, openstudio.Point3dVector)) + self.assertEqual(len(v), 3) + self.assertTrue(osut.areSame(vtx[0], v[0])) + self.assertTrue(osut.areSame(vtx[1], v[1])) + self.assertTrue(osut.areSame(vtx[4], v[2])) + self.assertEqual(o.status(), 0) + + # Check for (valid) convexity. + vtx = openstudio.Point3dVector() + vtx.append(openstudio.Point3d( 0, 0,10)) + vtx.append(openstudio.Point3d( 0, 0, 0)) + vtx.append(openstudio.Point3d(20, 0, 0)) + + v = osut.poly(vtx, True) + self.assertTrue(isinstance(v, openstudio.Point3dVector)) + self.assertEqual(len(v), 3) + self.assertEqual(o.status(), 0) + + # Check for (invalid) convexity. + vtx.append(openstudio.Point3d(1, 0, 1)) + v = osut.poly(vtx, True) + self.assertTrue(isinstance(v, openstudio.Point3dVector)) + self.assertFalse(v) + self.assertEqual(o.status(), 0) + + # 2nd check for (valid) convexity (with collinear points). + vtx = openstudio.Point3dVector() + vtx.append(openstudio.Point3d( 0, 0,10)) + vtx.append(openstudio.Point3d( 0, 0, 0)) + vtx.append(openstudio.Point3d(10, 0, 0)) + vtx.append(openstudio.Point3d(20, 0, 0)) + + v = osut.poly(vtx, True, False, False, False, "ulc") + self.assertTrue(isinstance(v, openstudio.Point3dVector)) + self.assertEqual(len(v), 4) + self.assertTrue(osut.areSame(vtx[0], v[0])) + self.assertTrue(osut.areSame(vtx[1], v[1])) + self.assertTrue(osut.areSame(vtx[2], v[2])) + self.assertTrue(osut.areSame(vtx[3], v[3])) + self.assertEqual(o.status(), 0) + + # 2nd check for (invalid) convexity (with collinear points). + vtx.append(openstudio.Point3d( 1, 0, 1)) + v = osut.poly(vtx, True, False, False, False, "ulc") + self.assertTrue(isinstance(v, openstudio.Point3dVector)) + self.assertFalse(v) + self.assertEqual(o.status(), 0) + + # 3rd check for (valid) convexity (with collinear points), yet returned + # 3D points vector become 'aligned' & clockwise. + vtx = openstudio.Point3dVector() + vtx.append(openstudio.Point3d( 0, 0,10)) + vtx.append(openstudio.Point3d( 0, 0, 0)) + vtx.append(openstudio.Point3d(10, 0, 0)) + vtx.append(openstudio.Point3d(20, 0, 0)) + + v = osut.poly(vtx, True, False, False, True, "cw") + self.assertTrue(isinstance(v, openstudio.Point3dVector)) + self.assertEqual(len(v), 4) + self.assertTrue(osut.shareXYZ(v, "z", 0)) + self.assertTrue(osut.isClockwise(v)) + self.assertEqual(o.status(), 0) + + # Ensure returned vector remains in original sequence (if unaltered). + vtx = openstudio.Point3dVector() + vtx.append(openstudio.Point3d( 0, 0,10)) + vtx.append(openstudio.Point3d( 0, 0, 0)) + vtx.append(openstudio.Point3d(10, 0, 0)) + vtx.append(openstudio.Point3d(20, 0, 0)) + + v = osut.poly(vtx, True, False, False, False, "no") + self.assertTrue(isinstance(v, openstudio.Point3dVector)) + self.assertEqual(len(v), 4) + self.assertTrue(osut.areSame(vtx[0], v[0])) + self.assertTrue(osut.areSame(vtx[1], v[1])) + self.assertTrue(osut.areSame(vtx[2], v[2])) + self.assertTrue(osut.areSame(vtx[3], v[3])) + self.assertFalse(osut.isClockwise(v)) + self.assertEqual(o.status(), 0) + + # Sequence of returned vector if altered (avoid collinearity). + vtx = openstudio.Point3dVector() + vtx.append(openstudio.Point3d( 0, 0,10)) + vtx.append(openstudio.Point3d( 0, 0, 0)) + vtx.append(openstudio.Point3d(10, 0, 0)) + vtx.append(openstudio.Point3d(20, 0, 0)) + + v = osut.poly(vtx, True, False, True, False, "no") + self.assertTrue(isinstance(v, openstudio.Point3dVector)) + self.assertEqual(len(v), 3) + self.assertTrue(osut.areSame(vtx[0], v[0])) + self.assertTrue(osut.areSame(vtx[1], v[1])) + self.assertTrue(osut.areSame(vtx[3], v[2])) + self.assertFalse(osut.isClockwise(v)) + self.assertEqual(o.status(), 0) # def test28_subsurface_insertions(self): From c88bea5a13d56147004347c5db43b7c6b30c65a4 Mon Sep 17 00:00:00 2001 From: brgix Date: Fri, 18 Jul 2025 07:47:53 -0400 Subject: [PATCH 23/49] Completes model transformation tests --- src/osut/osut.py | 43 ++++---- tests/test_osut.py | 260 ++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 280 insertions(+), 23 deletions(-) diff --git a/src/osut/osut.py b/src/osut/osut.py index 402b541..9c6017e 100644 --- a/src/osut/osut.py +++ b/src/osut/osut.py @@ -1298,7 +1298,6 @@ def isFenestrated(s=None) -> bool: return True - # ---- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- ---- # # ---- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- ---- # # This next set of utilities (~850 lines) help distinguish spaces that are @@ -4606,9 +4605,9 @@ def outline(a=[], bfr=0, flat=True) -> openstudio.Point3dVector: return oslg.negative("outline width", mth, CN.DBG, out) if yMAX < yMIN: return oslg.negative("outline height", mth, Cn.DBG, out) - if abs(xMIN - xMAX) < TOL: + if abs(xMIN - xMAX) < CN.TOL: return oslg.zero("outline width", mth, CN.DBG, out) - if abs(yMIN - yMAX) < TOL: + if abs(yMIN - yMAX) < CN.TOL: return oslg.zero("outline height", mth, CN.DBG, out) # Generate ULC point 3D vector. @@ -5003,45 +5002,45 @@ def realignedFace(pts=None, force=False) -> dict: return oslg.invalid("clockwise pts", mth, 1, CN.DBG, out) # Optionally force rotation so bounded box ends up wider than taller. - # Strongly suggested for flat surfaces like roofs (see 'sloped?'). + # Strongly suggested for flat surfaces like roofs (see 'isSloped'). try: force = bool(force) except: oslg.log(CN.DBG, "Ignoring force input (%s)" % mth) force = False - o = openstudio.Point3d(0, 0, 0) - w = width(pts) - h = height(pts) - d = h if h > w else w - - sgs = {} + o = openstudio.Point3d(0, 0, 0) + w = width(pts) + h = height(pts) + d = h if h > w else w box = boundedBox(pts) if not box: return oslg.invalid("bounded box", mth, 0, CN.DBG, out) + sgs = [] segs = segments(box) if not segs: return oslg.invalid("bounded box segments", mth, 0, CN.DBG, out) - # Deterministic ID of box rotation/translation 'origin'. - for idx, sg in enumerate(segs): - sgs[sg] = {} - sgs[sg]["idx"] = idx - sgs[sg]["mid"] = midpoint(sg[0], sg[1]) - sgs[sg]["l" ] = (sg[1] - sg[0]).length() - sgs[sg]["mo" ] = (sgs[sg]["mid"] - o).length() + # Deterministic identification of box rotation/translation 'origin'. + for idx, segment in enumerate(segs): + sg = {} + sg["idx"] = idx + sg["mid"] = midpoint(segment[0], segment[1]) + sg["l" ] = (segment[1] - segment[0]).length() + sg["mo" ] = (sg["mid"] - o).length() + sgs.append(sg) if isSquare(box): - sgs = dict(sorted(sgs.items(), key=lambda item: item[1]["mo"])[:2]) + sgs = sorted(sgs, key=lambda x: x["mo"])[:2] else: - sgs = dict(sorted(sgs.items(), key=lambda item: item[1]["l" ])[:2]) - sgs = dict(sorted(sgs.items(), key=lambda item: item[1]["mo"])[:2]) + sgs = sorted(sgs, key=lambda x: x["l" ])[:2] + sgs = sorted(sgs, key=lambda x: x["mo"])[:2] - sg0 = sgs.values[0] - sg1 = sgs.values[1] + sg0 = sgs[0] + sg1 = sgs[1] i = sg0["idx"] diff --git a/tests/test_osut.py b/tests/test_osut.py index 6365601..71e02a0 100644 --- a/tests/test_osut.py +++ b/tests/test_osut.py @@ -2383,7 +2383,265 @@ def test22_model_transformation(self): pts = r * a self.assertTrue(osut.areSame(pts, vtx)) - # ... to be completed later. + output1 = osut.realignedFace(vtx) + self.assertEqual(o.status(), 0) + self.assertTrue(isinstance(output1, dict)) + self.assertTrue("set" in output1) + self.assertTrue("box" in output1) + self.assertTrue("bbox" in output1) + self.assertTrue("t" in output1) + self.assertTrue("r" in output1) + self.assertTrue("o" in output1) + + ubox1 = output1[ "box"] + ubbox1 = output1["bbox"] + + # Realign a previously realigned surface? + output2 = osut.realignedFace(ubox1) + ubox2 = output1[ "box"] + ubbox2 = output1["bbox"] + + # Realigning a previously realigned polygon has no effect (== safe). + self.assertTrue(osut.areSame(ubox1, ubox2, False)) + self.assertTrue(osut.areSame(ubbox1, ubbox2, False)) + + bounded_area = openstudio.getArea(ubox1) + bounding_area = openstudio.getArea(ubbox1) + self.assertTrue(bounded_area) + self.assertTrue(bounding_area) + bounded_area = bounded_area.get() + bounding_area = bounding_area.get() + self.assertAlmostEqual(bounded_area, bounding_area, places=2) + + bounded_area = openstudio.getArea(ubox2) + bounding_area = openstudio.getArea(ubbox2) + self.assertTrue(bounded_area) + self.assertTrue(bounding_area) + bounded_area = bounded_area.get() + bounding_area = bounding_area.get() + self.assertAlmostEqual(bounded_area, bounding_area, places=2) + self.assertEqual(o.status(), 0) + + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + # Repeat with slight change in orientation. + vtx = openstudio.Point3dVector() + vtx.append(openstudio.Point3d( 2, 6, 0)) + vtx.append(openstudio.Point3d( 1, 4, 0)) + vtx.append(openstudio.Point3d( 5, 2, 0)) + vtx.append(openstudio.Point3d( 6, 4, 0)) + + output3 = osut.realignedFace(vtx) + ubox3 = output3[ "box"] + ubbox3 = output3["bbox"] + + # Realign a previously realigned surface? + output4 = osut.realignedFace(ubox3) + ubox4 = output4[ "box"] + ubbox4 = output4["bbox"] + + # Realigning a previously realigned polygon has no effect (== safe). + self.assertTrue(osut.areSame(ubox1, ubox3, False)) + self.assertTrue(osut.areSame(ubbox1, ubbox3, False)) + self.assertTrue(osut.areSame(ubox1, ubox4, False)) + self.assertTrue(osut.areSame(ubbox1, ubbox4, False)) + + bounded_area = openstudio.getArea(ubox3) + bounding_area = openstudio.getArea(ubbox3) + self.assertTrue(bounded_area) + self.assertTrue(bounding_area) + bounded_area = bounded_area.get() + bounding_area = bounding_area.get() + self.assertAlmostEqual(bounded_area, bounding_area, places=2) + + bounded_area = openstudio.getArea(ubox4) + bounding_area = openstudio.getArea(ubbox4) + self.assertTrue(bounded_area) + self.assertTrue(bounding_area) + bounded_area = bounded_area.get() + bounding_area = bounding_area.get() + self.assertAlmostEqual(bounded_area, bounding_area, places=2) + self.assertEqual(o.status(), 0) + + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + # Repeat with changes in vertex sequence. + # Repeat with slight change in orientation. + vtx = openstudio.Point3dVector() + vtx.append(openstudio.Point3d( 6, 4, 0)) + vtx.append(openstudio.Point3d( 5, 6, 0)) + vtx.append(openstudio.Point3d( 1, 4, 0)) + vtx.append(openstudio.Point3d( 2, 2, 0)) + + output5 = osut.realignedFace(vtx) + ubox5 = output5[ "box"] + ubbox5 = output5["bbox"] + + # Realign a previously realigned surface? + output6 = osut.realignedFace(ubox5) + ubox6 = output6[ "box"] + ubbox6 = output6["bbox"] + + # Realigning a previously realigned polygon has no effect (== safe). + self.assertTrue(osut.areSame(ubox1, ubox5)) + self.assertTrue(osut.areSame(ubox1, ubox6)) + self.assertTrue(osut.areSame(ubbox1, ubbox5)) + self.assertTrue(osut.areSame(ubbox1, ubbox6)) + self.assertTrue(osut.areSame(ubox5, ubox6, False)) + self.assertTrue(osut.areSame(ubox5, ubbox5, False)) + self.assertTrue(osut.areSame(ubbox5, ubox6, False)) + self.assertTrue(osut.areSame(ubox6, ubbox6, False)) + + bounded_area = openstudio.getArea(ubox5) + bounding_area = openstudio.getArea(ubbox5) + self.assertTrue(bounded_area) + self.assertTrue(bounding_area) + bounded_area = bounded_area.get() + bounding_area = bounding_area.get() + self.assertAlmostEqual(bounded_area, bounding_area, places=2) + + bounded_area = openstudio.getArea(ubox6) + bounding_area = openstudio.getArea(ubbox6) + self.assertTrue(bounded_area) + self.assertTrue(bounding_area) + bounded_area = bounded_area.get() + bounding_area = bounding_area.get() + self.assertAlmostEqual(bounded_area, bounding_area, places=2) + self.assertEqual(o.status(), 0) + + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + # Repeat with slight change in orientation (vertices resequenced). + vtx = openstudio.Point3dVector() + vtx.append(openstudio.Point3d( 5, 2, 0)) + vtx.append(openstudio.Point3d( 6, 4, 0)) + vtx.append(openstudio.Point3d( 2, 6, 0)) + vtx.append(openstudio.Point3d( 1, 4, 0)) + + output7 = osut.realignedFace(vtx) + ubox7 = output7[ "box"] + ubbox7 = output7["bbox"] + + # Realign a previously realigned surface? + output8 = osut.realignedFace(ubox7) + ubox8 = output8[ "box"] + ubbox8 = output8["bbox"] + + # Realigning a previously realigned polygon has no effect (== safe). + self.assertTrue(osut.areSame(ubox1, ubox7)) + self.assertTrue(osut.areSame(ubox1, ubox8)) + self.assertTrue(osut.areSame(ubbox1, ubbox7)) + self.assertTrue(osut.areSame(ubbox1, ubbox8)) + self.assertTrue(osut.areSame(ubox5, ubox7, False)) + self.assertTrue(osut.areSame(ubbox5, ubbox7, False)) + self.assertTrue(osut.areSame(ubox5, ubox5, False)) + self.assertTrue(osut.areSame(ubbox5, ubbox8, False)) + + bounded_area = openstudio.getArea(ubox7) + bounding_area = openstudio.getArea(ubbox7) + self.assertTrue(bounded_area) + self.assertTrue(bounding_area) + bounded_area = bounded_area.get() + bounding_area = bounding_area.get() + self.assertAlmostEqual(bounded_area, bounding_area, places=2) + + bounded_area = openstudio.getArea(ubox8) + bounding_area = openstudio.getArea(ubbox8) + self.assertTrue(bounded_area) + self.assertTrue(bounding_area) + bounded_area = bounded_area.get() + bounding_area = bounding_area.get() + self.assertAlmostEqual(bounded_area, bounding_area, places=2) + self.assertEqual(o.status(), 0) + + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + # Aligned box (wide). + vtx = openstudio.Point3dVector() + vtx.append(openstudio.Point3d( 2, 4, 0)) + vtx.append(openstudio.Point3d( 2, 2, 0)) + vtx.append(openstudio.Point3d( 6, 2, 0)) + vtx.append(openstudio.Point3d( 6, 4, 0)) + + output9 = osut.realignedFace(vtx) + ubox9 = output9[ "box"] + ubbox9 = output9["bbox"] + + output10 = osut.realignedFace(vtx, True) # no impact + ubox10 = output10[ "box"] + ubbox10 = output10["bbox"] + self.assertTrue(osut.areSame(ubox9, ubox10)) + self.assertTrue(osut.areSame(ubbox9, ubbox10)) + + # ... vs aligned box (narrow). + vtx = openstudio.Point3dVector() + vtx.append(openstudio.Point3d( 2, 6, 0)) + vtx.append(openstudio.Point3d( 2, 2, 0)) + vtx.append(openstudio.Point3d( 4, 2, 0)) + vtx.append(openstudio.Point3d( 4, 6, 0)) + + output11 = osut.realignedFace(vtx) + ubox11 = output11[ "box"] + ubbox11 = output11["bbox"] + + output12 = osut.realignedFace(vtx, True) # narrow, now wide + ubox12 = output12[ "box"] + ubbox12 = output12["bbox"] + self.assertFalse(osut.areSame(ubox11, ubox12)) + self.assertFalse(osut.areSame(ubbox11, ubbox12)) + self.assertTrue(osut.areSame(ubox12, ubox10)) + self.assertTrue(osut.areSame(ubbox12, ubbox10)) + + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + # Irregular surface (parallelogram). + vtx = openstudio.Point3dVector() + vtx.append(openstudio.Point3d( 4, 0, 0)) + vtx.append(openstudio.Point3d( 6, 4, 0)) + vtx.append(openstudio.Point3d( 3, 8, 0)) + vtx.append(openstudio.Point3d( 1, 4, 0)) + + output13 = osut.realignedFace(vtx) + uset13 = output13[ "set"] + ubox13 = output13[ "box"] + ubbox13 = output13["bbox"] + + # Pre-isolate bounded box (preferable with irregular surfaces). + box = osut.boundedBox(vtx) + output14 = osut.realignedFace(box) + uset14 = output14[ "set"] + ubox14 = output14[ "box"] + ubbox14 = output14["bbox"] + self.assertTrue(osut.areSame(uset14, ubox14)) + self.assertTrue(osut.areSame(uset14, ubbox14)) + self.assertFalse(osut.areSame(uset13, uset14)) + self.assertFalse(osut.areSame(ubox13, ubox14)) + self.assertFalse(osut.areSame(ubbox13, ubbox14)) + + rset14 = output14["r"] * (output14["t"] * uset14) + self.assertTrue(osut.areSame(box, rset14)) + + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + # Bounded box from an irregular, non-convex, "J"-shaped corridor roof. + # This is a VERY EXPENSIVE method when dealing with such HIGHLY + # CONVOLUTED polygons ! + # vtx = openstudio.Point3dVector() + # vtx.append(openstudio.Point3d( 0.0000000, 0.0000, 3.658)) + # vtx.append(openstudio.Point3d( 0.0000000, 35.3922, 3.658)) + # vtx.append(openstudio.Point3d( 7.4183600, 35.3922, 3.658)) + # vtx.append(openstudio.Point3d( 7.8150800, 35.2682, 3.658)) + # vtx.append(openstudio.Point3d( 13.8611000, 35.2682, 3.658)) + # vtx.append(openstudio.Point3d( 13.8611000, 38.9498, 3.658)) + # vtx.append(openstudio.Point3d( 7.8150800, 38.9498, 3.658)) + # vtx.append(openstudio.Point3d( 7.8150800, 38.6275, 3.658)) + # vtx.append(openstudio.Point3d( -0.0674713, 38.6275, 3.658)) + # vtx.append(openstudio.Point3d( -0.0674713, 48.6247, 3.658)) + # vtx.append(openstudio.Point3d( -2.5471900, 48.6247, 3.658)) + # vtx.append(openstudio.Point3d( -2.5471900, 38.5779, 3.658)) + # vtx.append(openstudio.Point3d( -6.7255500, 38.5779, 3.658)) + # vtx.append(openstudio.Point3d( -2.5471900, 2.7700, 3.658)) + # vtx.append(openstudio.Point3d(-14.9024000, 2.7700, 3.658)) + # vtx.append(openstudio.Point3d(-14.9024000, 0.0000, 3.658)) + # + # bbx = osut.boundedBox(vtx) + # self.assertTrue(osut.fits(bbx, vtx)) + # if o.logs(): print(mod1.logs()) + self.assertEqual(o.status(), 0) def test23_fits_overlaps(self): o = osut.oslg From 2f8ab94837e76fb72ba720bb80ee69cfc810b90c Mon Sep 17 00:00:00 2001 From: brgix Date: Fri, 18 Jul 2025 09:00:57 -0400 Subject: [PATCH 24/49] Adds (getter) 'roofs' (tested) --- src/osut/osut.py | 139 ++++++++++++++++++++++++++++++++++++++++++++- tests/test_osut.py | 93 +++++++++++++++++++++++++++++- 2 files changed, 228 insertions(+), 4 deletions(-) diff --git a/src/osut/osut.py b/src/osut/osut.py index 9c6017e..5fba633 100644 --- a/src/osut/osut.py +++ b/src/osut/osut.py @@ -3015,9 +3015,8 @@ def width(pts=None) -> float: if len(pts) < 2: return 0 xs = [pt.x() for pt in pts] - dx = max(xs) - min(xs) - return dx + return max(xs) - min(xs) def height(pts=None) -> float: @@ -5088,6 +5087,71 @@ def realignedFace(pts=None, force=False) -> dict: return out +def alignedWidth(pts=None, force=False) -> float: + """Returns 'width' of a set of OpenStudio 3D points, once re/aligned. + + Args: + pts (openstudio.Point3dVector): + A set of OpenStudio 3D points. + force (bool): + Whether to force rotation for aligned (yet narrow) boxes. + + Returns: + float: Width along X-axis, once re/aligned. + 0.0: If invalid inputs (see logs). + + """ + mth = "osut.alignedWidth" + pts = osut.poly(pts, False, True, True, True) + if len(pts) < 2: return 0 + + try: + force = bool(force) + except: + oslg.log(CN.DBG, "Ignoring force input (%s)" % mth) + force = False + + pts = osut.realignedFace(pts, force)["set"] + if len(pts) < 2: return 0 + + xs = [pt.x() for pt in pts] + + return max(xs) - min(xs) + + +def alignedHeight(pts=None, force=False) -> float: + """Returns 'height' of a set of OpenStudio 3D points, once re/aligned. + + Args: + pts (openstudio.Point3dVector): + A set of OpenStudio 3D points. + force (bool): + Whether to force rotation for aligned (yet narrow) boxes. + + Returns: + float: Height along Y-axis, once re/aligned. + 0.0: If invalid inputs (see logs). + + """ + + mth = "osut.alignedHeight" + pts = osut.poly(pts, False, True, True, True) + if len(pts) < 2: return 0 + + try: + force = bool(force) + except: + oslg.log(CN.DBG, "Ignoring force input (%s)" % mth) + force = False + + pts = osut.realignedFace(pts, force)["set"] + if len(pts) < 2: return 0 + + ys = [pt.y() for pt in pts] + + return max(ys) - min(ys) + + def facets(spaces=[], boundary="all", type="all", sides=[]) -> list: """Returns an array of OpenStudio space surfaces or subsurfaces that match criteria, e.g. exterior, north-east facing walls in hotel "lobby". Note @@ -5304,3 +5368,74 @@ def genSlab(pltz=[], z=0): slb = vtx return slb + + +def roofs(spaces = []) -> list: + """Returns outdoor-facing, space-related roof surfaces. These include + outdoor-facing roofs of each space per se, as well as any outdoor-facing + roof surface of unoccupied spaces immediately above (e.g. plenums, attics) + overlapping any of the ceiling surfaces of each space. It does not include + surfaces labelled as 'RoofCeiling', which do not comply with ASHRAE 90.1 or + NECB tilt criteria - see 'isRoof'. + + Args: + spaces (list): + Set of openstudio.model.Space instances. + + Returns: + list of openstudio.model.Surface instances: roofs (may be empty). + + """ + mth = "osut.getRoofs" + up = openstudio.Point3d(0,0,1) - openstudio.Point3d(0,0,0) + roofs = [] + if isinstance(spaces, openstudio.model.Space): spaces = [spaces] + + try: + spaces = list(spaces) + except: + spaces = [] + + spaces = [s for s in spaces if isinstance(s, openstudio.model.Space)] + + # Space-specific outdoor-facing roof surfaces. + roofs = facets(spaces, "Outdoors", "RoofCeiling") + roofs = [roof for roof in roofs if isRoof(roof)] + + for space in spaces: + # When unoccupied spaces are involved (e.g. plenums, attics), the + # target space may not share the same local transformation as the + # space(s) above. Fetching site transformation. + t0 = transforms(space) + if not t0["t"]: continue + + t0 = t0["t"] + + for ceiling in facets(space, "Surface", "RoofCeiling"): + cv0 = t0 * ceiling.vertices() + + floor = ceiling.adjacentSurface() + if not floor: continue + + other = floor.get().space() + if not other: continue + + other = other.get() + if other.partofTotalFloorArea(): continue + + ti = transforms(other) + if not ti["t"]: continue + + ti = ti["t"] + + # @todo: recursive call for stacked spaces as atria (AirBoundaries). + for ruf in facets(other, "Outdoors", "RoofCeiling"): + if not isRoof(ruf): continue + + rvi = ti * ruf.vertices() + cst = cast(cv0, rvi, up) + if not overlapping(cst, rvi, False): continue + + if ruf not in roofs: roofs.append(ruf) + + return roofs diff --git a/tests/test_osut.py b/tests/test_osut.py index 71e02a0..1576418 100644 --- a/tests/test_osut.py +++ b/tests/test_osut.py @@ -3851,7 +3851,97 @@ def test27_polygon_attributes(self): # def test31_convexity(self): - # def test32_outdoor_roofs(self): + def test32_outdoor_roofs(self): + o = osut.oslg + self.assertEqual(o.status(), 0) + self.assertEqual(o.reset(INF), INF) + self.assertEqual(o.level(), INF) + self.assertEqual(o.status(), 0) + + translator = openstudio.osversion.VersionTranslator() + + path = openstudio.path("./tests/files/osms/in/5ZoneNoHVAC.osm") + model = translator.loadModel(path) + self.assertTrue(model) + model = model.get() + + spaces = {} + roofs = {} + + for space in model.getSpaces(): + for s in space.surfaces(): + if s.surfaceType().lower() != "roofceiling": continue + if s.outsideBoundaryCondition().lower() != "outdoors": continue + + self.assertFalse(space.nameString() in spaces) + spaces[space.nameString()] = s.nameString() + + self.assertEqual(len(spaces), 5) + + # for key, value in spaces.items(): print(key, value) + # "Story 1 East Perimeter Space" "Surface 18" + # "Story 1 North Perimeter Space" "Surface 12" + # "Story 1 Core Space" "Surface 30" + # "Story 1 South Perimeter Space" "Surface 24" + # "Story 1 West Perimeter Space" "Surface 6" + + for space in model.getSpaces(): + rufs = osut.roofs(space) + self.assertEqual(len(rufs), 1) + ruf = rufs[0] + self.assertTrue(isinstance(ruf, openstudio.model.Surface)) + roofs[space.nameString()] = ruf.nameString() + + self.assertEqual(len(roofs), len(spaces)) + + for id, surface in spaces.items(): + self.assertTrue(id in roofs.keys()) + self.assertEqual(roofs[id], surface) + + del(model) + + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + # CASE 2: None of the occupied spaces have outdoor-facing roofs, yet + # plenum above has 4 outdoor-facing roofs (each matches a space ceiling). + path = openstudio.path("./tests/files/osms/out/seb2.osm") + model = translator.loadModel(path) + self.assertTrue(model) + model = model.get() + + occupied = [] + spaces = {} + roofs = {} + + for space in model.getSpaces(): + if not space.partofTotalFloorArea(): continue + + occupied.append(space.nameString()) + + for s in space.surfaces(): + if s.surfaceType().lower() != "roofceiling": continue + if s.outsideBoundaryCondition().lower() != "outdoors": continue + + self.assertFalse(space.nameString() in spaces) + spaces[space.nameString()] = s.nameString() + + self.assertEqual(len(occupied), 4) + self.assertFalse(spaces) + + for space in model.getSpaces(): + if not space.partofTotalFloorArea(): continue + + rufs = osut.roofs(space) + self.assertEqual(len(rufs), 1) + ruf = rufs[0] + self.assertTrue(isinstance(ruf, openstudio.model.Surface)) + roofs[space.nameString()] = ruf.nameString() + + self.assertEqual(len(roofs), 4) + self.assertEqual(o.status(), 0) + + for occ in occupied: + self.assertTrue(occ in roofs.keys()) + self.assertTrue("plenum" in roofs[occ].lower()) # def test33_leader_line_anchors_inserts(self): @@ -3864,7 +3954,6 @@ def test35_facet_retrieval(self): self.assertEqual(o.level(), DBG) self.assertEqual(o.status(), 0) - version = int("".join(openstudio.openStudioVersion().split("."))) translator = openstudio.osversion.VersionTranslator() path = openstudio.path("./tests/files/osms/out/seb2.osm") From d49f52af51828c5bd0f864549b1ebf303b17355f Mon Sep 17 00:00:00 2001 From: brgix Date: Fri, 18 Jul 2025 09:14:37 -0400 Subject: [PATCH 25/49] Adds 'isDaylit' (untested) --- src/osut/osut.py | 57 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/src/osut/osut.py b/src/osut/osut.py index 5fba633..e2d1f6d 100644 --- a/src/osut/osut.py +++ b/src/osut/osut.py @@ -5439,3 +5439,60 @@ def roofs(spaces = []) -> list: if ruf not in roofs: roofs.append(ruf) return roofs + + + # @return [Bool] + # @return [false] if invalid input (see logs) +def isDaylit(space=None, sidelit=True, toplit=True, baselit=True) -> bool: + """Validates whether space has outdoor-facing surfaces with fenestration. + + Args: + space (openstudio.model.Space): + An OpenStudio space. + sidelit (bool): + Whether to check for 'sidelighting', e.g. windows. + toplit (bool): + Whether to check for 'toplighting', e.g. skylights. + baselit (bool): + Whether to check for 'baselighting', e.g. glazed floors. + + Returns: + bool: Whether space is daylit. + False: If invalid inputs (see logs). + + """ + mth = "osut.isDaylit" + cl = openstudio.model.Space + walls = [] + roofs = [] + floors = [] + + if not isinstance(space, openstudio.model.Space): + return oslg.mismatch("space", space, cl, mth, CN.DBG, False) + + try: + sidelit = bool(sidelit) + except: + return oslg.invalid("sidelit", mth, 2, CN.DBG, False) + + try: + toplit = bool(toplit) + except: + return oslg.invalid("toplit", mth, 2, CN.DBG, False) + + try: + baselit = bool(baselit) + except: + return oslg.invalid("baselit", mth, 2, CN.DBG, False) + + if sidelit: walls = facets(space, "Outdoors", "Wall") + if toplit: roofs = facets(space, "Outdoors", "RoofCeiling") + if baselit: floors = facets(space, "Outdoors", "Floor") + + for surface in (walls + roofs + floors): + for sub in surface.subSurfaces(): + # All fenestrated subsurface types are considered, as user can set + # these explicitly (e.g. skylight in a wall) in OpenStudio. + if isFenestration(sub): return True + + return False From 8f27186fc0960ba5dbb286128bf14bcf548366d9 Mon Sep 17 00:00:00 2001 From: brgix Date: Fri, 18 Jul 2025 09:16:50 -0400 Subject: [PATCH 26/49] Minor cleanup --- src/osut/osut.py | 4 +--- tests/test_osut.py | 3 +++ 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/osut/osut.py b/src/osut/osut.py index e2d1f6d..792b7f7 100644 --- a/src/osut/osut.py +++ b/src/osut/osut.py @@ -5441,8 +5441,6 @@ def roofs(spaces = []) -> list: return roofs - # @return [Bool] - # @return [false] if invalid input (see logs) def isDaylit(space=None, sidelit=True, toplit=True, baselit=True) -> bool: """Validates whether space has outdoor-facing surfaces with fenestration. @@ -5459,7 +5457,7 @@ def isDaylit(space=None, sidelit=True, toplit=True, baselit=True) -> bool: Returns: bool: Whether space is daylit. False: If invalid inputs (see logs). - + """ mth = "osut.isDaylit" cl = openstudio.model.Space diff --git a/tests/test_osut.py b/tests/test_osut.py index 1576418..6e85790 100644 --- a/tests/test_osut.py +++ b/tests/test_osut.py @@ -3943,6 +3943,9 @@ def test32_outdoor_roofs(self): self.assertTrue(occ in roofs.keys()) self.assertTrue("plenum" in roofs[occ].lower()) + del(model) + self.assertEqual(o.status(), 0) + # def test33_leader_line_anchors_inserts(self): # def test34_generated_skylight_wells(self): From 89ba3735704238977d353aeaa76cbf4ec2b8691a Mon Sep 17 00:00:00 2001 From: brgix Date: Fri, 18 Jul 2025 14:30:59 -0400 Subject: [PATCH 27/49] Partial implementation of convexity tests --- tests/test_osut.py | 186 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 185 insertions(+), 1 deletion(-) diff --git a/tests/test_osut.py b/tests/test_osut.py index 6e85790..90e11e9 100644 --- a/tests/test_osut.py +++ b/tests/test_osut.py @@ -3849,7 +3849,191 @@ def test27_polygon_attributes(self): # def test30_wwr_insertions(self): - # def test31_convexity(self): + def test31_convexity(self): + o = osut.oslg + self.assertEqual(o.status(), 0) + self.assertEqual(o.reset(INF), INF) + self.assertEqual(o.level(), INF) + self.assertEqual(o.status(), 0) + translator = openstudio.osversion.VersionTranslator() + version = int("".join(openstudio.openStudioVersion().split("."))) + + # Successful test. + path = openstudio.path("./tests/files/osms/in/smalloffice.osm") + model = translator.loadModel(path) + self.assertTrue(model) + model = model.get() + core = None + attic = None + + for space in model.getSpaces(): + id = space.nameString() + + if version >= 350: + self.assertTrue(space.isVolumeAutocalculated) + self.assertTrue(space.isCeilingHeightAutocalculated) + self.assertTrue(space.isFloorAreaDefaulted) + self.assertTrue(space.isFloorAreaAutocalculated) + + if id == "Attic": + self.assertFalse(space.partofTotalFloorArea()) + attic = space + continue + + # Isolate core as being part of the total floor area (occupied zone) + # and not having sidelighting. + self.assertTrue(space.partofTotalFloorArea()) + if space.exteriorWallArea() > TOL: continue + + core = space + + srfs = core.surfaces() + core_floor = [s for s in srfs if s.surfaceType() == "Floor"] + core_ceiling = [s for s in srfs if s.surfaceType() == "RoofCeiling"] + + self.assertEqual(len(core_floor), 1) + self.assertEqual(len(core_ceiling), 1) + core_floor = core_floor[0] + core_ceiling = core_ceiling[0] + attic_floor = core_ceiling.adjacentSurface() + self.assertTrue(attic_floor) + attic_floor = attic_floor.get() + + self.assertTrue("Core" in core.nameString()) + # 22.69, 13.46, 0, !- X,Y,Z Vertex 1 {m} + # 22.69, 5.00, 0, !- X,Y,Z Vertex 2 {m} + # 5.00, 5.00, 0, !- X,Y,Z Vertex 3 {m} + # 5.00, 13.46, 0; !- X,Y,Z Vertex 4 {m} + # -----,------,-- + # 17.69 x 8.46 = 149.66 m2 + self.assertAlmostEqual(core.floorArea(), 149.66, places=2) + core_volume = core.floorArea() * 3.05 + self.assertAlmostEqual(core_volume, core.volume(), places=2) + + # OpenStudio versions prior to v351 overestimate attic volume + # (798.41 m3), as they resort to floor area x height. + if version < 350: + self.assertAlmostEqual(attic.volume(), 798.41, places=2) + else: + self.assertAlmostEqual(attic.volume(), 720.19, places=2) + + # Attic floor area includes overhang 'floor' surfaces (i.e. soffits). + self.assertAlmostEqual(attic.floorArea(), 567.98, places=2) + self.assertTrue(osut.poly(core_floor, True)) # convex + self.assertTrue(osut.poly(core_ceiling, True)) # convex + self.assertTrue(osut.poly(attic_floor, True)) # convex + self.assertEqual(o.status(), 0) + + # Insert new 'mini' (2m x 2m) floor/ceiling at the centre of the + # existing core space. Initial insertion resorting strictly to adding + # leader lines from the initial core floor/ceiling vertices to the new + # 'mini' floor/ceiling. + centre = openstudio.getCentroid(core_floor.vertices()) + self.assertTrue(centre) + centre = centre.get() + mini_w = centre.x() - 1 # 12.845 + mini_e = centre.x() + 1 # 14.845 + mini_n = centre.y() + 1 # 10.230 + mini_s = centre.y() - 1 # 8.230 + + mini_floor_vtx = openstudio.Point3dVector() + mini_floor_vtx.append(openstudio.Point3d(mini_e, mini_n, 0)) + mini_floor_vtx.append(openstudio.Point3d(mini_e, mini_s, 0)) + mini_floor_vtx.append(openstudio.Point3d(mini_w, mini_s, 0)) + mini_floor_vtx.append(openstudio.Point3d(mini_w, mini_n, 0)) + mini_floor = openstudio.model.Surface(mini_floor_vtx, model) + mini_floor.setName("Mini floor") + self.assertEqual(mini_floor.outsideBoundaryCondition(), "Ground") + self.assertTrue(mini_floor.setSpace(core)) + + mini_ceiling_vtx = openstudio.Point3dVector() + mini_ceiling_vtx.append(openstudio.Point3d(mini_w, mini_n, 3.05)) + mini_ceiling_vtx.append(openstudio.Point3d(mini_w, mini_s, 3.05)) + mini_ceiling_vtx.append(openstudio.Point3d(mini_e, mini_s, 3.05)) + mini_ceiling_vtx.append(openstudio.Point3d(mini_e, mini_n, 3.05)) + mini_ceiling = openstudio.model.Surface(mini_ceiling_vtx, model) + mini_ceiling.setName("Mini ceiling") + self.assertTrue(mini_ceiling.setSpace(core)) + + mini_attic_vtx = openstudio.Point3dVector() + mini_attic_vtx.append(openstudio.Point3d(mini_e, mini_n, 3.05)) + mini_attic_vtx.append(openstudio.Point3d(mini_e, mini_s, 3.05)) + mini_attic_vtx.append(openstudio.Point3d(mini_w, mini_s, 3.05)) + mini_attic_vtx.append(openstudio.Point3d(mini_w, mini_n, 3.05)) + mini_attic = openstudio.model.Surface(mini_attic_vtx, model) + mini_attic.setName("Mini attic") + self.assertTrue(mini_attic.setSpace(attic)) + + self.assertTrue(mini_ceiling.setAdjacentSurface(mini_attic)) + self.assertEqual(mini_ceiling.outsideBoundaryCondition(), "Surface") + self.assertEqual(mini_attic.outsideBoundaryCondition(), "Surface") + self.assertEqual(mini_ceiling.outsideBoundaryCondition(), "Surface") + self.assertEqual(mini_ceiling.outsideBoundaryCondition(), "Surface") + self.assertTrue(mini_ceiling.adjacentSurface()) + self.assertTrue(mini_attic.adjacentSurface()) + self.assertEqual(mini_ceiling.adjacentSurface().get(), mini_attic) + self.assertEqual(mini_attic.adjacentSurface().get(), mini_ceiling) + + # Reset existing core floor, core ceiling & attic floor vertices to + # accommodate 3x new mini 'holes' (filled in by the 3x new 'mini' + # surfaces). 'Hole' vertices are defined in the opposite 'winding' of + # their 'mini' counterparts (e.g. clockwise if the initial vertex + # sequence is counterclockwise). To ensure valid (core and attic) area + # & volume calculations (and avoid OpenStudio stdout errors/warnings), + # append the last vertex of the original surface: each EnergyPlus edge + # must be referenced (at least) twice (i.e. the 'leader line' between + # each of the 3x original surfaces and each of the 'mini' holes must + # be doubled). + vtx = openstudio.Point3dVector() + for v in core_floor.vertices(): vtx.append(v) + vtx.append(mini_floor_vtx[3]) + vtx.append(mini_floor_vtx[2]) + vtx.append(mini_floor_vtx[1]) + vtx.append(mini_floor_vtx[0]) + vtx.append(mini_floor_vtx[3]) + vtx.append(vtx[3]) + self.assertTrue(core_floor.setVertices(vtx)) + + vtx = openstudio.Point3dVector() + for v in core_ceiling.vertices(): vtx.append(v) + vtx.append(mini_ceiling_vtx[1]) + vtx.append(mini_ceiling_vtx[0]) + vtx.append(mini_ceiling_vtx[3]) + vtx.append(mini_ceiling_vtx[2]) + vtx .append(mini_ceiling_vtx[1]) + vtx.append(vtx[3]) + self.assertTrue(core_ceiling.setVertices(vtx)) + + vtx = openstudio.Point3dVector() + for v in attic_floor.vertices(): vtx.append(v) + vtx .append(mini_attic_vtx[3]) + vtx.append(mini_attic_vtx[2]) + vtx.append(mini_attic_vtx[1]) + vtx.append(mini_attic_vtx[0]) + vtx.append(mini_attic_vtx[3]) + vtx.append(vtx[3]) + self.assertTrue(attic_floor.setVertices(vtx)) + + # Generate (temporary) OSM & IDF: + model.save("./tests/files/osms/out/miniX.osm", True) + + # ft = openstudio.energyplus.ForwardTranslator() + # idf = ft.translateModel(model) + # idf.save("./tests/files/osms/out/miniX.idf", True) + + # Add 2x skylights to attic. + attic_south = model.getSurfaceByName("Attic_roof_south") + self.assertTrue(attic_south) + attic_south = attic_south.get() + + aligned = osut.poly(attic_south, False, False, True, True, "ulc") + side = 1.2 + offset = side + 1 + head = osut.height(aligned) - 0.2 + self.assertAlmostEqual(head, 10.16, places=2) + + del(model) + self.assertEqual(o.status(), 0) def test32_outdoor_roofs(self): o = osut.oslg From 635adfd07722ca1164c3aec0f7022938e7e2ab1d Mon Sep 17 00:00:00 2001 From: brgix Date: Sat, 19 Jul 2025 16:16:47 -0400 Subject: [PATCH 28/49] Implements 'addSubs' (initial testing, more to come ...) --- src/osut/osut.py | 682 +++++++++++++++++++++++++++++++++++++++++++++ tests/test_osut.py | 217 ++++++++++++++- 2 files changed, 898 insertions(+), 1 deletion(-) diff --git a/src/osut/osut.py b/src/osut/osut.py index 792b7f7..049832f 100644 --- a/src/osut/osut.py +++ b/src/osut/osut.py @@ -29,6 +29,7 @@ import re import math +import numpy import collections import openstudio from oslg import oslg @@ -43,6 +44,8 @@ class _CN: 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 CN = _CN() # General surface orientations (see 'facets' method). @@ -5441,6 +5444,685 @@ def roofs(spaces = []) -> list: return roofs +def addSubs(s=None, subs=[], clear=False, bound=False, realign=False, bfr=0.005): + """Adds sub surface(s) (e.g. windows, doors, skylights) to a surface. + + Args: + s (openstudio.model.Surface): + An OpenStudio surface. + subs (list): + Requested subsurface attributes (dicts): + - "id" (str): identifier e.g. "Window 007" + - "type" (str): OpenStudio subsurface type ("FixedWindow") + - "count" (int): number of individual subs per array (1) + - "multiplier" (int): OpenStudio subsurface multiplier (1) + - "frame" (WindowPropertyFrameAndDivider): FD object (None) + - "assembly" (ConstructionBase): OpenStudio construction (None) + - "ratio" (float): %FWR [0.0, 1.0] + - "head" (float): e.g. door height, incl frame (osut.CN.HEAD) + - "sill" (float): e.g. door sill (incl frame) (osut.CN.SILL) + - "height" (float): door sill-to-head height + - "width" (float): e.g. door width + - "offset" (float): left-right gap between e.g. doors + - "centreline" (float): centreline left-right offset of subs vs base + - "r_buffer" (float): gap between subs and right corner + - "l_buffer" (float): gap between subs and left corner + "clear" (bool): + Whether to remove current sub surfaces. + "bound" (bool): + Whether to add subs with regards to surface's bounded box. + "realign" (bool): + Whether to first realign bounded box. + "bfr" (float): + Safety buffer, to maintain near other edges. + + Returns: + bool: Whether addition(s) was/were successful. + False: If invalid inputs (see logs). + + """ + mth = "osut.addSubs" + cl1 = openstudio.model.Surface + cl2 = openstudio.model.WindowPropertyFrameAndDivider + cl3 = openstudio.model.ConstructionBase + v = int("".join(openstudio.openStudioVersion().split("."))) + min = 0.050 # minimum ratio value ( 5%) + max = 0.950 # maximum ratio value (95%) + if isinstance(subs, dict): sbs = [subs] + + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + # Exit if mismatched or invalid argument classes. + try: + subs = list(subs) + except: + return oslg.mismatch("subs", subs, list, mth, CN.DBG, False) + + if len(subs) == 0: + return oslg.empty("subs", mth, CN.DBG, False) + + if not isinstance(s, cl1): + return oslg.mismatch("surface", s, cl1, mth, CN.DBG, False) + + if not poly(s): + return oslg.empty("surface points", mth, CN.DBG, False) + + nom = s.nameString() + mdl = s.model() + + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + # Purge existing sub surfaces? + try: + clear = bool(clear) + except: + oslg.log(CN.WRN, "%s: Keeping existing sub surfaces (%s)" % (nom, mth)) + clear = False + + if clear: + for sb in s.subSurfaces(): sb.remove() + + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + # Add sub surfaces with respect to base surface's bounded box? This is + # often useful (in some cases necessary) with irregular or concave surfaces. + # If true, sub surface parameters (e.g. height, offset, centreline) no + # longer apply to the original surface 'bounding' box, but instead to its + # largest 'bounded' box. This can be combined with the 'realign' parameter. + try: + bound = bool(bound) + except: + oslg.log(CN.WRN, "%s: Ignoring bounded box (%s)" % (nom, mth)) + bound = False + + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + # Force re-alignment of base surface (or its 'bounded' box)? False by + # default (ideal for vertical/tilted walls & sloped roofs). If set to True + # for a narrow wall for instance, an array of sub surfaces will be added + # from bottom to top (rather from left to right). + try: + realign = bool(realign) + except: + oslg.log(CN.WRN, "%s: Ignoring realignment (%s)" % (nom, mth)) + realign = False + + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + # Ensure minimum safety buffer. + try: + bfr = float(bfr) + except: + oslg.log(CN.ERR, "Setting safety buffer to 5mm (%s)" % mth) + bfr = 0.005 + + if round(bfr, 2) < 0: + return oslg.negative("safety buffer", mth, CN.ERR, False) + + if round(bfr, 2) < 0.005: + m = "Safety buffer < 5mm may generate invalid geometry (%s)" % mth + oslg.log(CN.WRN, m) + + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + # Allowable sub surface types | Frame&Divider enabled? + # - "FixedWindow" | True + # - "OperableWindow" | True + # - "Door" | False + # - "GlassDoor" | True + # - "OverheadDoor" | False + # - "Skylight" | False if v < 321 + # - "TubularDaylightDome" | False + # - "TubularDaylightDiffuser" | False + type = "FixedWindow" + types = openstudio.model.SubSurface.validSubSurfaceTypeValues() + stype = s.surfaceType() # Wall, RoofCeiling or Floor + + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + t = openstudio.Transformation.alignFace(s.vertices()) + s0 = poly(s, False, False, False, t, "ulc") + s00 = None + + # Adapt sandbox if user selects to 'bound' and/or 'realign'. + if bound: + box = boundedBox(s0) + + if realign: + s00 = realignedFace(box, True) + + if not s00["set"]: + return oslg.invalid("bound realignment", mth, 0, CN.DBG, False) + + elif realign: + s00 = realignedFace(s0, False) + + if not s00["set"]: + return oslg.invalid("unbound realignment", mth, 0, CN.DBG, False) + + max_x = width( s00["set"]) if s00 else width(s0) + max_y = height(s00["set"]) if s00 else height(s0) + mid_x = max_x / 2 + mid_y = max_y / 2 + + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + # Assign default values to certain sub keys (if missing), +more validation. + for index, sub in subs: + if not isinstance(sub, dict): + return oslg.mismatch("sub", sub, dict, mth, CN.DBG, False) + + # Required key:value pairs (either set by the user or defaulted). + if "frame" not in sub: sub["frame" ] = None + if "assembly" not in sub: sub["assembly" ] = None + if "count" not in sub: sub["count" ] = 1 + if "multiplier" not in sub: sub["multiplier"] = 1 + if "id" not in sub: sub["id" ] = "" + if "type" not in sub: sub["type" ] = 1 + + sub["type"] = trim(sub["type"]) + sub["id" ] = trim(sub["id"]) + + if not sub["type"]: sub["type"] = type + if not sub["id" ]: sub["id" ] = "osut:%s:%d" % (nom, index) + + try: + sub["count"] = int(sub["count"]) + except: + sub["count"] = 1 + + try: + sub["multiplier"] = int(sub["multiplier"]) + except: + sub["multiplier"] = 1 + + if sub["count" ] < 1: sub["count" ] = 1 + if sub["multiplier"] < 1: sub["multiplier"] = 1 + + id = sub["id"] + + # If sub surface type is invalid, log/reset. Additional corrections may + # be enabled once a sub surface is actually instantiated. + if sub["type"] not in types: + m = "Reset invalid '%s' type to '%s' (%s)" % (id, type, mth) + oslg.log(CN.WRN, m) + sub["type"] = type + + # Log/ignore (optional) frame & divider object. + if sub["frame"]: + if isinstance(sub["frame"], cl2): + if sub["type"].lower() == "skylight" and v < 321: + sub["frame"] = None + if sub["type"].lower() == "door": + sub["frame"] = None + if sub["type"].lower() == "overheaddoor": + sub["frame"] = None + if sub["type"].lower() == "tubulardaylightdome": + sub["frame"] = None + if sub["type"].lower() == "tubulardaylightdiffuser": + sub["frame"] = None + + if sub["frame"] is None: + m = "Skip '%s' FrameDivider (%s)" % (id, mth) + oslg.log(CN.WRN, m) + else: + m = "Skip '%s' invalid FrameDivider object (%s)" % (id, mth) + oslg.log(CN.WRN, m) + sub["frame"] = None + + # The (optional) "assembly" must reference a valid OpenStudio + # construction base, to explicitly assign to each instantiated sub + # surface. If invalid, log/reset/ignore. Additional checks are later + # activated once a sub surface is actually instantiated. + if sub["assembly"]: + if not isinstance(sub["assembly"], cl3): + m = "Skip invalid '%s' construction (%s)" % (id, mth) + log(WRN, m) + sub["assembly"] = None + + # Log/reset negative float values. Set ~0.0 values to 0.0. + for key, value in subs.items(): + if key == "count": continue + if key == "multiplier": continue + if key == "type": continue + if key == "id": continue + if key == "frame": continue + if key == "assembly": continue + + try: + value = float(value) + except: + return oslg.mismatch(key, value, float, mth, CN.DBG, False) + + if key == "centreline": continue + + if value < 0: oslg.negative(key, mth, CN.WRN) + if abs(value) < CN.TOL: value = 0.0 + + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + # Log/reset (or abandon) conflicting user-set geometry key:value pairs: + # "head" e.g. std 80" door + frame/buffers (+ m) + # "sill" e.g. std 30" sill + frame/buffers (+ m) + # "height" any sub surface height, below "head" (+ m) + # "width" e.g. 1.2 m + # "offset" if array (+ m) + # "centreline" left or right of base surface centreline (+/- m) + # "r_buffer" buffer between sub/array and right-side corner (+ m) + # "l_buffer" buffer between sub/array and left-side corner (+ m) + # + # If successful, this will generate sub surfaces and add them to the model. + for sub in subs: + # Set-up unique sub parameters: + # - Frame & Divider "width" + # - minimum "clear glazing" limits + # - buffers, etc. + id = sub["id"] + frame = sub["frame"].frameWidth() if sub["frame"] else 0 + frames = 2 * frame + buffer = frame + bfr + buffers = 2 * buffer + dim = 3 * frame if 3 * frame > 0.200 else 0.200 + glass = dim - frames + min_sill = buffer + min_head = buffers + glass + max_head = max_y - buffer + max_sill = max_head - (buffers + glass) + min_ljamb = buffer + max_ljamb = max_x - (buffers + glass) + min_rjamb = buffers + glass + max_rjamb = max_x - buffer + max_height = max_y - buffers + max_width = max_x - buffers + + # Default sub surface "head" & "sill" height, unless user-specified. + typ_head = CN.HEAD + typ_sill = CN.SILL + + if "ratio" in sub: + if sub["ratio"] > 0.75 or stype.lower() != "wall": + typ_head = mid_y * (1 + sub["ratio"]) + typ_sill = mid_y * (1 - sub["ratio"]) + + # Log/reset "height" if beyond min/max. + if "height" in sub: + if (sub["height"] < glass - CN.TOL2 or + sub["height"] > max_height + CN.TOL2): + + m = "(Re)set '%s' height %.3fm (%s)" % (id, sub["height"], mth) + oslg.log(CN.WRN, m) + sub["height"] = numpy.clip(sub["height"], glass, max_height) + m = "Height '%s' reset to %.3fm (%s)" % (id, sub["height"], mth) + oslg.log(CN.WRN, m) + + # Log/reset "head" height if beyond min/max. + if "head" in sub: + if (sub["head"] < min_head - CN.TOL2 or + sub["head"] > max_head + CN.TOL2): + + m = "(Re)set '%s' head %.3fm (%s)" % (id, sub["head"], mth) + oslg.log(CN.WRN, m) + sub["head"] = numpy.clip(sub["head"], min_head, max_head) + m = "Head '%s' reset to %.3fm (%s)" % (id, sub["head"], mth) + oslg.log(CN.WRN, m) + + # Log/reset "sill" height if beyond min/max. + if "sill" in sub: + if (sub["head"] < min_sill - CN.TOL2 or + sub["head"] > max_sill + CN.TOL2): + + m = "(Re)set '%s' sill %.3fm (%s)" % (id, sub["sill"], mth) + log(CN.WRN, m) + sub["sill"] = numpy.clip(sub["sill"], min_sill, max_sill) + m = "Sill '%s reset to %.3fm (%s)" % (id, sub["sill"], mth) + log(CN.WRN, m) + + # At this point, "head", "sill" and/or "height" have been tentatively + # validated (and/or have been corrected) independently from one another. + # Log/reset "head" & "sill" heights if conflicting. + if "head" in sub and "sill" in sub and sub["head"] < sub["sill"] + glass: + sill = sub["head"] - glass + + if sill < min_sill - CN.TOL2: + sub["count" ] = 0 + sub["multiplier"] = 0 + + if "ratio" in sub: sub["ratio" ] = 0 + if "height" in sub: sub["height"] = 0 + if "width" in sub: sub["width" ] = 0 + + m = "Skip: invalid '%s' head/sill combo (%s)" % (id, mth) + oslg.log(CN.ERR, m) + continue + else: + m = "(Re)set '%s' sill %.3fm (%s)" % (id, sub["sill"], mth) + oslg.log(CN.WRN, m) + sub["sill"] = sill + m = "Sill '%s' (re)set to %.3fm (%s)" % (id, sub["sill"], mth) + oslg.log(CN.WRN, m) + + # Attempt to reconcile "head", "sill" and/or "height". If successful, + # all 3x parameters are set (if missing), or reset if invalid. + if "head" in sub and "sill" in sub: + height = sub["head"] - sub["sill"] + + if "height" in sub and abs(sub["height"] - height) > CN.TOL2: + m1 = "(Re)set '%s' height %.3fm (%s)" % (id, sub["height"], mth) + m2 = "Height '%s' (re)set %.3fm (%s)" % (id, height, mth) + oslg.log(CN.WRN, m1) + oslg.log(CN.WRN, m2) + + sub["height"] = height + + elif "head" in sub:# no "sill" + if "height" in sub: + sill = sub["head"] - sub["height"] + + if sill < min_sill - CN.TOL2: + sill = min_sill + height = sub["head"] - sill + + if height < glass: + sub["count" ] = 0 + sub["multiplier"] = 0 + + if "ratio" in sub: sub["ratio" ] = 0 + if "height" in sub: sub["height"] = 0 + if "width" in sub: sub["width" ] = 0 + + m = "Skip: invalid '%s' head/height combo (%s)" % (id, mth) + oslg.log(CN.ERR, m) + continue + else: + m = "(Re)set '%s' height %.3fm (%s)" % (id, sub["height"], mth) + log(CN.WRN, m) + sub["sill" ] = sill + sub["height"] = height + m = "Height '%s' re(set) %.3fm (%s)" % (id, sub["height"], mth) + log(CN.WRN, m) + else: + sub["sill"] = sill + else: + sub["sill" ] = typ_sill + sub["height"] = sub["head"] - sub["sill"] + + elif "sill" in sub: # no "head" + if "height" in sub: + head = sub["sill"] + sub["height"] + + if head > max_head - CN.TOL2: + head = max_head + height = head - sub["sill"] + + if height < glass: + sub["count" ] = 0 + sub["multiplier"] = 0 + + if "ratio" in sub: sub["ratio" ] = 0 + if "height" in sub: sub["height"] = 0 + if "width" in sub: sub["width" ] = 0 + + m = "Skip: invalid '%s' sill/height combo (%s)" % (id, mth) + oslg.log(CN.ERR, m) + continue + else: + m = "(Re)set '%s' height %.3fm (%s)" % (id, sub["height"], mth) + oslg.log(CN.WRN, m) + sub["head" ] = head + sub["height"] = height + m = "Height '%s' reset to %.3fm (%s)" % (id, sub["height"], mth) + oslg.log(CN.WRN, m) + else: + sub["head"] = head + else: + sub["head" ] = typ_head + sub["height"] = sub["head"] - sub["sill"] + + elif "height" in sub: # neither "head" nor "sill" + head = mid_y + sub["height"]/2 if s00 else typ_head + sill = head - sub["height"] + + if sill < min_sill: + sill = min_sill + head = sill + sub["height"] + + sub["head"] = head + sub["sill"] = sill + + else: + sub["head" ] = typ_head + sub["sill" ] = typ_sill + sub["height"] = sub["head"] - sub["sill"] + + # Log/reset "width" if beyond min/max. + if "width" in sub: + if (sub["width"] < glass - CN.TOL2 or + sub["width"] > max_width + CN.TOL2): + + m = "(Re)set '%s' width %.3fm (%s)" % (id, sub["width"], mth) + oslg.log(CN.WRN, m) + sub["width"] = numpy.clip(sub["width"], glass, max_width) + m = "Width '%s' reset to %.3fm ()%s)" % (id, sub["width"], mth) + oslg.log(CN.WRN, m) + + # Log/reset "count" if < 1 (or not an Integer) + try: + sub["count"] = int(sub["count"]) + except: + sub["count"] = 1 + + if sub["count"] < 1: + sub["count"] = 1 + oslg.log(CN.WRN, "Reset '%s' count to min 1 (%s)" % (id, mth)) + + # Log/reset if left-sided buffer under min jamb position. + if "l_buffer" in sub: + if sub["l_buffer"] < min_ljamb - CN.TOL: + m = "(Re)set '%s' left buffer %.3fm (%s)" % (id, sub["l_buffer"], mth) + log(WRN, m) + sub["l_buffer"] = min_ljamb + m = "Left buffer '%s' reset to %.3fm (%s)" % (id, sub["l_buffer"], mth) + log(WRN, m) + + # Log/reset if right-sided buffer beyond max jamb position. + if "r_buffer" in sub: + if sub["r_buffer"] > max_rjamb - CN.TOL: + m = "(Re)set '%s' right buffer %.3fm (%s)" % (id, sub["r_buffer"], mth) + log(CN.WRN, m) + sub["r_buffer"] = min_rjamb + m = "Right buffer '%s' reset to %.3fm (%s)" % (id, sub["r_buffer"], mth) + log(CN.WRN, m) + + centre = mid_x + if "centreline" in sub: centre += sub["centreline"] + + n = sub["count" ] + h = sub["height"] + frames + w = 0 # overall width of sub(s) bounding box (to calculate) + x0 = 0 # left-side X-axis coordinate of sub(s) bounding box + xf = 0 # right-side X-axis coordinate of sub(s) bounding box + + # Log/reset "offset", if conflicting vs "width". + if "ratio" in sub: + if sub["ratio"] < CN.TOL: + sub["ratio" ] = 0 + sub["count" ] = 0 + sub["multiplier"] = 0 + + if "height" in sub: sub["height"] = 0 + if "width" in sub: sub["width" ] = 0 + + oslg.log(CN.ERR, "Skip: ratio ~0 (%s)" % mth) + continue + + # Log/reset if "ratio" beyond min/max? + if sub["ratio"] < min and sub["ratio"] > max: + m = "(Re)set ratio %.3f (%s)" % (sub["ratio"], mth) + oslg.log(CN.WRN, m) + sub["ratio"] = numpy.clip(sub["ratio"], min, max) + m = "Ratio reset to %.3f (%s)" % (sub["ratio"], mth) + oslg.log(CN.WRN, m) + + # Log/reset "count" unless 1. + if sub["count"] != 1: + sub["count"] = 1 + oslg.log(CN.WRN, "Count (ratio) reset to 1 (%s)" % mth) + + area = s.grossArea() * sub["ratio"] # sub m2, incl. frames + w = area / h + width = w - frames + x0 = centre - w/2 + xf = centre + w/2 + + if "l_buffer" in sub: + if "centreline" in sub: + m = "Skip '%s' left buffer (vs centreline) (%s)" % (id, mth) + oslg.log(CN.WRN, m) + else: + x0 = sub["l_buffer"] - frame + xf = x0 + w + centre = x0 + w/2 + elif "r_buffer" in sub: + if "centreline" in sub: + m = "Skip '%s' right buffer (vs centreline) (%s)" % (id, mth) + oslg.log(CN.WRN, m) + else: + xf = max_x - sub["r_buffer"] + frame + x0 = xf - w + centre = x0 + w/2 + + # Too wide? + if x0 < min_ljamb - CN.TOL2 or xf > max_rjamb - CN.TOL2: + sub["count" ] = 0 + sub["multiplier"] = 0 + + if "ratio" in sub: sub[ratio ] = 0 + if "height" in sub: sub[height] = 0 + if "width" in sub: sub[width ] = 0 + + m = "Skip '%s': invalid (ratio) width/centreline (%s)" % (id, mth) + oslg.log(CN.ERR, m) + continue + + if "width" in sub and abs(sub["width"] - width) > CN.TOL: + m = "(Re)set '%s' width (ratio) %.3fm (%s)" % (id, sub["width"], mth) + oslg.log(CN.WRN, m) + sub["width"] = width + m = "Width (ratio) '%s' reset to %.3fm (%s)" % (id, sub["width"], mth) + oslg.log(CN.WRN, m) + + if "width" not in sub: sub["width"] = width + + else: + if "width" not in sub: + sub["count" ] = 0 + sub["multiplier"] = 0 + + if "ratio" in sub: sub["ratio" ] = 0 + if "height" in sub: sub["height"] = 0 + if "width" in sub: sub["width" ] = 0 + + oslg.log(CN.ERR, "Skip: missing '%s' width (%s})" % (id, mth)) + continue + + width = sub["width"] + frames + gap = (max_x - n * width) / (n + 1) + + if "offset" in sub: gap = sub["offset"] - width + if gap < buffer: gap = 0 + + offset = gap + width + + if "offset" in sub and abs(offset - sub["offset"]) > CN.TOL: + m = "(Re)set '%s' sub offset %.3fm (%s)" % (id, sub["offset"], mth) + log(CN.WRN, m) + sub["offset"] = offset + m = "Sub offset (%s) reset to %.3fm (%s)" % (id, sub["offset"], mth) + log(CN.WRN, m) + + if "offset" not in sub.key: sub["offset"] = offset + + # Overall width (including frames) of bounding box around array. + w = n * width + (n - 1) * gap + x0 = centre - w/2 + xf = centre + w/2 + + if "l_buffer" in sub: + if "centreline" in sub: + m = "Skip '%s' left buffer (vs centreline) (%s)" % (id, mth) + oslg.log(CN.WRN, m) + else: + x0 = sub["l_buffer"] - frame + xf = x0 + w + centre = x0 + w/2 + elif "r_buffer" in sub: + if "centreline" in sub: + m = "Skip '%s' right buffer (vs centreline) (%s)" % (id, mth) + log(WRN, m) + else: + xf = max_x - sub["r_buffer"] + frame + x0 = xf - w + centre = x0 + w/2 + + # Too wide? + if x0 < buffer - CN.TOL2 or xf > max_x - buffer - CN.TOL2: + sub[:count ] = 0 + sub[:multiplier] = 0 + if "ratio" in sub: sub["ratio" ] = 0 + if "height" in sub: sub["height"] = 0 + if "width" in sub: sub["width" ] = 0 + m = "Skip: invalid array width/centreline (%s)" % mth + oslg.log(CN.ERR, m) + continue + + # Initialize left-side X-axis coordinate of only/first sub. + pos = x0 + frame + + # Generate sub(s). + for i in range(sub["count"]): + name = "%s:%d" % (id, i) + fr = sub["frame"].frameWidth() if sub["frame"] else 0 + vec = openstudio.Point3dVector() + vec.append(openstudio.Point3d(pos, sub["head"], 0)) + vec.append(openstudio.Point3d(pos, sub["sill"], 0)) + vec.append(openstudio.Point3d(pos+sub["width"], sub["sill"], 0)) + vec.append(openstudio.Point3d(pos+sub["width"], sub["head"], 0)) + vec = t * (s00["r"] * (s00["t"] * vec)) if s00 else t * vec + + # Log/skip if conflict between individual sub and base surface. + vc = offset(vc, fr, 300) if fr > 0 else p3Dv(vec) + + if not fits(vc, s): + m = "Skip '%s': won't fit in '%s' (%s)" % (name, nom, mth) + oslg.log(CN.ERR, m) + break + + # Log/skip if conflicts with existing subs (even if same array). + conflict = False + + for sb in s.subSurfaces(): + fd = sb.windowPropertyFrameAndDivider() + fr = fd.get().frameWidth() if fd else 0 + vk = sb.vertices() + if fr > 0: vk = offset(vk, fr, 300) + + if overlapping(vc, vk): + nome = sb.nameString() + m = "Skip '%s': overlaps '%s' (%s)" % (name, nome, mth) + oslg.log(CN.ERR, m) + conflict = True + break + + if conflict: break + + sb = openstudio.model.SubSurface(vec, mdl) + sb.setName(name) + sb.setSubSurfaceType(sub["type"]) + if sub["assembly"]: sb.setConstruction(sub["assembly"]) + if sub["multiplier"] > 1: sb.setMultiplier(sub["multiplier"]) + + if sub["frame"] and sb.allowWindowPropertyFrameAndDivider(): + sb.setWindowPropertyFrameAndDivider(sub["frame"]) + + sb.setSurface(s) + + # Reset "pos" if array. + if offset in sub: pos += sub["offset"] + + return True + + def isDaylit(space=None, sidelit=True, toplit=True, baselit=True) -> bool: """Validates whether space has outdoor-facing surfaces with fenestration. diff --git a/tests/test_osut.py b/tests/test_osut.py index 6e85790..7cf7a5e 100644 --- a/tests/test_osut.py +++ b/tests/test_osut.py @@ -3843,7 +3843,222 @@ def test27_polygon_attributes(self): self.assertFalse(osut.isClockwise(v)) self.assertEqual(o.status(), 0) - # def test28_subsurface_insertions(self): + def test28_subsurface_insertions(self): + # Examples of how to harness OpenStudio's Boost geometry methods to + # safely insert subsurfaces along rotated/tilted/slanted base surfaces. + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + o = osut.oslg + self.assertEqual(o.status(), 0) + self.assertEqual(o.reset(INF), INF) + self.assertEqual(o.level(), INF) + self.assertEqual(o.status(), 0) + translator = openstudio.osversion.VersionTranslator() + + v = int("".join(openstudio.openStudioVersion().split("."))) + + # Successful test. + path = openstudio.path("./tests/files/osms/out/seb2.osm") + model = translator.loadModel(path) + self.assertTrue(model) + model = model.get() + + openarea = model.getSpaceByName("Open area 1") + self.assertTrue(openarea) + openarea = openarea.get() + + if v >= 350: + self.assertTrue(openarea.isEnclosedVolume()) + self.assertTrue(openarea.isVolumeDefaulted()) + self.assertTrue(openarea.isVolumeAutocalculated()) + + w5 = model.getSurfaceByName("Openarea 1 Wall 5") + self.assertTrue(w5) + w5 = w5.get() + + w5_space = w5.space() + self.assertTrue(w5_space) + w5_space = w5_space.get() + self.assertEqual(w5_space, openarea) + self.assertEqual(len(w5.vertices()), 4) + + # Delete w5, and replace with 1x slanted roof + 3x walls (1x tilted). + # Keep w5 coordinates in memory (before deleting), as anchor points for + # the 4x new surfaces. + w5_0 = w5.vertices()[0] + w5_1 = w5.vertices()[1] + w5_2 = w5.vertices()[2] + w5_3 = w5.vertices()[3] + + w5.remove() + + # 2x new points. + roof_left = openstudio.Point3d( 0.2166, 12.7865, 2.3528) + roof_right = openstudio.Point3d(-5.4769, 11.2626, 2.3528) + length = (roof_left - roof_right).length() + + + # New slanted roof. + vec = openstudio.Point3dVector() + vec.append(w5_0) + vec.append(roof_left) + vec.append(roof_right) + vec.append(w5_3) + roof = openstudio.model.Surface(vec, model) + roof.setName("Openarea slanted roof") + self.assertTrue(roof.setSurfaceType("RoofCeiling")) + self.assertTrue(roof.setSpace(openarea)) + + # Side-note test: genConstruction --- --- --- --- --- --- --- --- --- # + self.assertTrue(roof.isConstructionDefaulted()) + lc = roof.construction() + self.assertTrue(lc) + lc = lc.get().to_LayeredConstruction() + self.assertTrue(lc) + lc = lc.get() + c = osut.genConstruction(model, dict(type="roof", uo=1/5.46)) + self.assertEqual(o.status(), 0) + self.assertTrue(isinstance(c, openstudio.model.LayeredConstruction)) + self.assertTrue(roof.setConstruction(c)) + self.assertFalse(roof.isConstructionDefaulted()) + r1 = osut.rsi(lc) + r2 = osut.rsi(c) + d1 = osut.rsi(lc) + d2 = osut.rsi(c) + self.assertTrue(abs(r1 - r2) > 0) + self.assertTrue(abs(d1 - d2) > 0) + # ... end of genConstruction test --- --- --- --- --- --- --- --- --- # + + # New, inverse-tilted wall (i.e. cantilevered), under new slanted roof. + vec = openstudio.Point3dVector() + # vec.append(roof_left) # TOPLEFT + # vec.append(w5_1) # BOTTOMLEFT + # vec.append(w5_2) # BOTTOMRIGHT + # vec.append(roof_right) # TOPRIGHT + + # Test if starting instead from BOTTOMRIGHT (i.e. upside-down "U"). + vec.append(w5_2) # BOTTOMRIGHT + vec.append(roof_right) # TOPRIGHT + vec.append(roof_left) # TOPLEFT + vec.append(w5_1) # BOTTOMLEFT + + tilt_wall = openstudio.model.Surface(vec, model) + tilt_wall.setName("Openarea tilted wall") + self.assertTrue(tilt_wall.setSurfaceType("Wall")) + self.assertTrue(tilt_wall.setSpace(openarea)) + + # New, left side wall. + vec = openstudio.Point3dVector() + vec.append(w5_0) + vec.append(w5_1) + vec.append(roof_left) + left_wall = openstudio.model.Surface(vec, model) + left_wall.setName("Openarea left side wall") + self.assertTrue(left_wall.setSpace(openarea)) + + # New, right side wall. + vec = openstudio.Point3dVector() + vec.append(w5_3) + vec.append(roof_right) + vec.append(w5_2) + right_wall = openstudio.model.Surface(vec, model) + right_wall.setName("Openarea right side wall") + self.assertTrue(right_wall.setSpace(openarea)) + + if v >= 350: + self.assertTrue(openarea.isEnclosedVolume) + self.assertTrue(openarea.isVolumeDefaulted) + self.assertTrue(openarea.isVolumeAutocalculated) + + model.save("./tests/files/osms/out/seb_mod.osm", True) + + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + # Fetch transform if tilted wall vertices were to "align", i.e.: + # - rotated/tilted + # - then flattened along XY plane + # - all Z-axis coordinates == ~0 + # - vertices with the lowest X-axis values are aligned along X-axis (0) + # - vertices with the lowest Z-axis values ares aligned along Y-axis (0) + # - Z-axis values are represented as Y-axis values + tr = openstudio.Transformation.alignFace(tilt_wall.vertices()) + aligned_tilt_wall = tr.inverse() * tilt_wall.vertices() + # for pt in aligned_tilt_wall: print(pt) + # [4.89, 0.00, 0.00] # if BOTTOMRIGHT, i.e. upside-down "U" + # [5.89, 3.09, 0.00] + # [0.00, 3.09, 0.00] + # [1.00, 0.00, 0.00] + # ... no change in results (once sub surfaces are added below), as + # 'addSubs' does not rely 'directly' on World or Relative XYZ + # coordinates of the base surface. It instead relies on base surface + # width/height (once 'aligned'), regardless of the user-defined + # sequence of vertices. + + # Find centerline along "aligned" X-axis, and upper Y-axis limit. + min_x = 0 + max_x = 0 + max_y = 0 + + for vec in aligned_tilt_wall: + if vec.x() < min_x: min_x = vec.x() + if vec.x() > max_x: max_x = vec.x() + if vec.y() > max_y: max_y = vec.y() + + centerline = (max_x - min_x) / 2 + self.assertAlmostEqual(centerline * 2, length, places=2) + + # Subsurface dimensions (e.g. window/skylight). + width = 0.5 + height = 1.0 + + # Add 3x new, tilted windows along the tilted wall upper horizontal edge + # (i.e. max_Y), then realign with original tilted wall. Insert using 5mm + # buffer, IF inserted along any host/parent/base surface edge, e.g. door + # sill. Boost-based alignement/realignment does introduce small errors, + # and EnergyPlus may raise warnings of overlaps between host/base/parent + # surface and any of its new subsurface(s). Why 5mm (vs 25mm)? Keeping + # buffer under 10mm, see: https://rd2.github.io/tbd/pages/subs.html. + y = max_y - 0.005 + + x = centerline - width / 2 # center window + vec = openstudio.Point3dVector() + vec.append(openstudio.Point3d(x, y, 0)) + vec.append(openstudio.Point3d(x, y - height, 0)) + vec.append(openstudio.Point3d(x + width, y - height, 0)) + vec.append(openstudio.Point3d(x + width, y, 0)) + + tilt_window1 = openstudio.model.SubSurface(tr * vec, model) + tilt_window1.setName("Tilted window (center)") + self.assertTrue(tilt_window1.setSubSurfaceType("FixedWindow")) + self.assertTrue(tilt_window1.setSurface(tilt_wall)) + + x = centerline - 3*width/2 - 0.15 # window to the left of the first one + vec = openstudio.Point3dVector() + vec.append(openstudio.Point3d(x, y, 0)) + vec.append(openstudio.Point3d(x, y - height, 0)) + vec.append(openstudio.Point3d(x + width, y - height, 0)) + vec.append(openstudio.Point3d(x + width, y, 0)) + + tilt_window2 = openstudio.model.SubSurface(tr * vec, model) + tilt_window2.setName("Tilted window (left)") + self.assertTrue(tilt_window2.setSubSurfaceType("FixedWindow")) + self.assertTrue(tilt_window2.setSurface(tilt_wall)) + + x = centerline + width/2 + 0.15 # window to the right of the first one + vec = openstudio.Point3dVector() + vec.append(openstudio.Point3d(x, y, 0)) + vec.append(openstudio.Point3d(x, y - height, 0)) + vec.append(openstudio.Point3d(x + width, y - height, 0)) + vec.append(openstudio.Point3d(x + width, y, 0)) + + tilt_window3 = openstudio.model.SubSurface(tr * vec, model) + tilt_window3.setName("Tilted window (right)") + self.assertTrue(tilt_window3.setSubSurfaceType("FixedWindow")) + self.assertTrue(tilt_window3.setSurface(tilt_wall)) + + # model.save("./tests/files/osms/out/seb_fen.osm", True) + del(model) + + self.assertEqual(o.status(), 0) + # def test29_surface_width_height(self): From 1e4a90a4e2be024c83a209ea1b73feec9473b9e2 Mon Sep 17 00:00:00 2001 From: brgix Date: Sat, 19 Jul 2025 16:23:26 -0400 Subject: [PATCH 29/49] Updates pyproject.toml --- pyproject.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 0a24a3d..8141f58 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,8 +7,13 @@ requires-python = ">=3.2" authors = [ {name = "Denis Bourgeois", email = "denis@rd2.ca"} ] maintainers = [ {name = "Denis Bourgeois", email = "denis@rd2.ca"} ] dependencies = [ + "re", + "math", + "numpy", + "collections" "oslg", "openstudio>=3.6.1", + "dataclasses", ] license = "BSD-3-Clause" license-files = ["LICENSE"] From a0bf3592a890584da512bca7eff058d2e6ca3cde Mon Sep 17 00:00:00 2001 From: brgix Date: Sat, 19 Jul 2025 16:25:57 -0400 Subject: [PATCH 30/49] Adds numpy (dependency) in GH Actions pull_request.yml --- .github/workflows/pull_request.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 2c2b78b..e50a75d 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -19,7 +19,7 @@ jobs: with: python-version: ${{ matrix.python-version }} - name: Install dependencies - run: python -m pip install --upgrade pip setuptools wheel openstudio oslg + run: python -m pip install --upgrade pip setuptools wheel openstudio oslg numpy - name: Run unit tests run: python -m unittest @@ -37,6 +37,6 @@ jobs: with: python-version: ${{ matrix.python-version }} - name: Install dependencies - run: python -m pip install --upgrade pip setuptools wheel openstudio oslg + run: python -m pip install --upgrade pip setuptools wheel openstudio oslg numpty - name: Run unit tests run: python -m unittest From f838db4465b52cbfc2f4fea78c4ca5884681fbb5 Mon Sep 17 00:00:00 2001 From: brgix Date: Sat, 19 Jul 2025 16:42:34 -0400 Subject: [PATCH 31/49] Further testing of 'addSubs' (control case) --- tests/test_osut.py | 77 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 74 insertions(+), 3 deletions(-) diff --git a/tests/test_osut.py b/tests/test_osut.py index 1f8e760..008376e 100644 --- a/tests/test_osut.py +++ b/tests/test_osut.py @@ -4055,10 +4055,81 @@ def test28_subsurface_insertions(self): self.assertTrue(tilt_window3.setSurface(tilt_wall)) # model.save("./tests/files/osms/out/seb_fen.osm", True) - del(model) - self.assertEqual(o.status(), 0) + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + # Repeat for 3x skylights. Fetch transform if slanted roof vertices were + # also to "align". Recover the (default) window construction. + self.assertTrue(tilt_window1.isConstructionDefaulted()) + construction = tilt_window1.construction() + self.assertTrue(construction) + construction = construction.get() + + tr = openstudio.Transformation.alignFace(roof.vertices()) + aligned_roof = tr.inverse() * roof.vertices() + + # Find centerline along "aligned" X-axis, and lower Y-axis limit. + min_x = 0 + max_x = 0 + min_y = 0 + + for vec in aligned_tilt_wall: + if vec.x() < min_x: min_x = vec.x() + if vec.x() > max_x: max_x = vec.x() + if vec.y() < min_y: min_y = vec.y() + + centerline = (max_x - min_x) / 2 + self.assertAlmostEqual(centerline * 2, length, places=2) + + # Add 3x new, slanted skylights aligned along upper horizontal edge of + # roof (i.e. min_Y), then realign with original roof. + y = min_y + 0.005 + x = centerline - width / 2 # center skylight + vec = openstudio.Point3dVector() + vec.append(openstudio.Point3d(x, y + height, 0)) + vec.append(openstudio.Point3d(x, y, 0)) + vec.append(openstudio.Point3d(x + width, y, 0)) + vec.append(openstudio.Point3d(x + width, y + height, 0)) + + skylight1 = openstudio.model.SubSurface(tr * vec, model) + skylight1.setName("Skylight (center)") + self.assertTrue(skylight1.setSubSurfaceType("Skylight")) + self.assertTrue(skylight1.setConstruction(construction)) + self.assertTrue(skylight1.setSurface(roof)) + + x = centerline - 3*width/2 - 0.15 # skylight to the left of center + vec = openstudio.Point3dVector() + vec.append(openstudio.Point3d(x, y + height, 0)) + vec.append(openstudio.Point3d(x, y , 0)) + vec.append(openstudio.Point3d(x + width, y , 0)) + vec.append(openstudio.Point3d(x + width, y + height, 0)) + + skylight2 = openstudio.model.SubSurface(tr * vec, model) + skylight2.setName("Skylight (left)") + self.assertTrue(skylight2.setSubSurfaceType("Skylight")) + self.assertTrue(skylight2.setConstruction(construction)) + self.assertTrue(skylight2.setSurface(roof)) + + x = centerline + width/2 + 0.15 # skylight to the right of center + vec = openstudio.Point3dVector() + vec.append(openstudio.Point3d(x, y + height, 0)) + vec.append(openstudio.Point3d(x, y , 0)) + vec.append(openstudio.Point3d(x + width, y , 0)) + vec.append(openstudio.Point3d(x + width, y + height, 0)) + + skylight3 = openstudio.model.SubSurface(tr * vec, model) + skylight3.setName("Skylight (right)") + self.assertTrue(skylight3.setSubSurfaceType("Skylight")) + self.assertTrue(skylight3.setConstruction(construction)) + self.assertTrue(skylight3.setSurface(roof)) + + model.save("./tests/files/osms/out/seb_ext1.osm", True) + + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + # Now test the same result when relying on OSut::addSub TO DO. + + del(model) + self.assertEqual(o.status(), 0) # def test29_surface_width_height(self): @@ -4246,7 +4317,7 @@ def test31_convexity(self): offset = side + 1 head = osut.height(aligned) - 0.2 self.assertAlmostEqual(head, 10.16, places=2) - + del(model) self.assertEqual(o.status(), 0) From 4f575550f982a44b9e4cded2394a6643a84b9f41 Mon Sep 17 00:00:00 2001 From: brgix Date: Mon, 21 Jul 2025 07:06:39 -0400 Subject: [PATCH 32/49] Completes 1st round of 'addSubs' testing --- src/osut/osut.py | 148 +++++++++++++++--------------- tests/test_osut.py | 222 +++++++++++++++++++++++++-------------------- 2 files changed, 200 insertions(+), 170 deletions(-) diff --git a/src/osut/osut.py b/src/osut/osut.py index 049832f..3fb57d7 100644 --- a/src/osut/osut.py +++ b/src/osut/osut.py @@ -2264,7 +2264,7 @@ def setpoints(space=None): space = dad.get() cnd = tg2 else: - log(ERR, "Unknown space %s (%s)" % (id, mth)) + oslg.log(ERR, "Unknown space %s (%s)" % (id, mth)) # 3. Fetch space setpoints (if model indeed holds valid setpoints). heated = hasHeatingTemperatureSetpoints(space.model()) @@ -2347,7 +2347,7 @@ def isRefrigerated(space=None): if status: status = status.get() if isinstance(status, bool): return status - log(ERR, "Unknown %s REFRIGERATED %s (%s)" % (id, status, mth)) + oslg.log(ERR, "Unknown %s REFRIGERATED %s (%s)" % (id, status, mth)) # 2. Else, compare design heating/cooling setpoints. stps = setpoints(space) @@ -3023,7 +3023,7 @@ def width(pts=None) -> float: def height(pts=None) -> float: - """Returns 'width' of a set of OpenStudio 3D points. + """Returns 'height' of a set of OpenStudio 3D points. Args: pts (openstudio.Point3dVector): @@ -5105,7 +5105,7 @@ def alignedWidth(pts=None, force=False) -> float: """ mth = "osut.alignedWidth" - pts = osut.poly(pts, False, True, True, True) + pts = poly(pts, False, True, True, True) if len(pts) < 2: return 0 try: @@ -5114,7 +5114,7 @@ def alignedWidth(pts=None, force=False) -> float: oslg.log(CN.DBG, "Ignoring force input (%s)" % mth) force = False - pts = osut.realignedFace(pts, force)["set"] + pts = realignedFace(pts, force)["set"] if len(pts) < 2: return 0 xs = [pt.x() for pt in pts] @@ -5138,7 +5138,7 @@ def alignedHeight(pts=None, force=False) -> float: """ mth = "osut.alignedHeight" - pts = osut.poly(pts, False, True, True, True) + pts = poly(pts, False, True, True, True) if len(pts) < 2: return 0 try: @@ -5147,7 +5147,7 @@ def alignedHeight(pts=None, force=False) -> float: oslg.log(CN.DBG, "Ignoring force input (%s)" % mth) force = False - pts = osut.realignedFace(pts, force)["set"] + pts = realignedFace(pts, force)["set"] if len(pts) < 2: return 0 ys = [pt.y() for pt in pts] @@ -5488,7 +5488,7 @@ def addSubs(s=None, subs=[], clear=False, bound=False, realign=False, bfr=0.005) v = int("".join(openstudio.openStudioVersion().split("."))) min = 0.050 # minimum ratio value ( 5%) max = 0.950 # maximum ratio value (95%) - if isinstance(subs, dict): sbs = [subs] + if isinstance(subs, dict): subs = [subs] # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # # Exit if mismatched or invalid argument classes. @@ -5600,7 +5600,7 @@ def addSubs(s=None, subs=[], clear=False, bound=False, realign=False, bfr=0.005) # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # # Assign default values to certain sub keys (if missing), +more validation. - for index, sub in subs: + for index, sub in enumerate(subs): if not isinstance(sub, dict): return oslg.mismatch("sub", sub, dict, mth, CN.DBG, False) @@ -5610,10 +5610,10 @@ def addSubs(s=None, subs=[], clear=False, bound=False, realign=False, bfr=0.005) if "count" not in sub: sub["count" ] = 1 if "multiplier" not in sub: sub["multiplier"] = 1 if "id" not in sub: sub["id" ] = "" - if "type" not in sub: sub["type" ] = 1 + if "type" not in sub: sub["type" ] = type - sub["type"] = trim(sub["type"]) - sub["id" ] = trim(sub["id"]) + sub["type"] = oslg.trim(sub["type"]) + sub["id" ] = oslg.trim(sub["id"]) if not sub["type"]: sub["type"] = type if not sub["id" ]: sub["id" ] = "osut:%s:%d" % (nom, index) @@ -5669,11 +5669,11 @@ def addSubs(s=None, subs=[], clear=False, bound=False, realign=False, bfr=0.005) if sub["assembly"]: if not isinstance(sub["assembly"], cl3): m = "Skip invalid '%s' construction (%s)" % (id, mth) - log(WRN, m) + oslg.log(WRN, m) sub["assembly"] = None # Log/reset negative float values. Set ~0.0 values to 0.0. - for key, value in subs.items(): + for key, value in sub.items(): if key == "count": continue if key == "multiplier": continue if key == "type": continue @@ -5740,7 +5740,7 @@ def addSubs(s=None, subs=[], clear=False, bound=False, realign=False, bfr=0.005) if (sub["height"] < glass - CN.TOL2 or sub["height"] > max_height + CN.TOL2): - m = "(Re)set '%s' height %.3fm (%s)" % (id, sub["height"], mth) + m = "Reset '%s' height %.3fm (%s)" % (id, sub["height"], mth) oslg.log(CN.WRN, m) sub["height"] = numpy.clip(sub["height"], glass, max_height) m = "Height '%s' reset to %.3fm (%s)" % (id, sub["height"], mth) @@ -5751,7 +5751,7 @@ def addSubs(s=None, subs=[], clear=False, bound=False, realign=False, bfr=0.005) if (sub["head"] < min_head - CN.TOL2 or sub["head"] > max_head + CN.TOL2): - m = "(Re)set '%s' head %.3fm (%s)" % (id, sub["head"], mth) + m = "Reset '%s' head %.3fm (%s)" % (id, sub["head"], mth) oslg.log(CN.WRN, m) sub["head"] = numpy.clip(sub["head"], min_head, max_head) m = "Head '%s' reset to %.3fm (%s)" % (id, sub["head"], mth) @@ -5759,14 +5759,14 @@ def addSubs(s=None, subs=[], clear=False, bound=False, realign=False, bfr=0.005) # Log/reset "sill" height if beyond min/max. if "sill" in sub: - if (sub["head"] < min_sill - CN.TOL2 or - sub["head"] > max_sill + CN.TOL2): + if (sub["sill"] < min_sill - CN.TOL2 or + sub["sill"] > max_sill + CN.TOL2): - m = "(Re)set '%s' sill %.3fm (%s)" % (id, sub["sill"], mth) - log(CN.WRN, m) + m = "Reset '%s' sill %.3fm (%s)" % (id, sub["sill"], mth) + oslg.log(CN.WRN, m) sub["sill"] = numpy.clip(sub["sill"], min_sill, max_sill) m = "Sill '%s reset to %.3fm (%s)" % (id, sub["sill"], mth) - log(CN.WRN, m) + oslg.log(CN.WRN, m) # At this point, "head", "sill" and/or "height" have been tentatively # validated (and/or have been corrected) independently from one another. @@ -5786,34 +5786,34 @@ def addSubs(s=None, subs=[], clear=False, bound=False, realign=False, bfr=0.005) oslg.log(CN.ERR, m) continue else: - m = "(Re)set '%s' sill %.3fm (%s)" % (id, sub["sill"], mth) + m = "Reset '%s' sill %.3fm (%s)" % (id, sub["sill"], mth) oslg.log(CN.WRN, m) sub["sill"] = sill - m = "Sill '%s' (re)set to %.3fm (%s)" % (id, sub["sill"], mth) + m = "Sill '%s' reset to %.3fm (%s)" % (id, sub["sill"], mth) oslg.log(CN.WRN, m) # Attempt to reconcile "head", "sill" and/or "height". If successful, # all 3x parameters are set (if missing), or reset if invalid. if "head" in sub and "sill" in sub: - height = sub["head"] - sub["sill"] + hght = sub["head"] - sub["sill"] - if "height" in sub and abs(sub["height"] - height) > CN.TOL2: - m1 = "(Re)set '%s' height %.3fm (%s)" % (id, sub["height"], mth) - m2 = "Height '%s' (re)set %.3fm (%s)" % (id, height, mth) + if "height" in sub and abs(sub["height"] - hght) > CN.TOL2: + m1 = "Reset '%s' height %.3fm (%s)" % (id, sub["height"], mth) + m2 = "Height '%s' reset %.3fm (%s)" % (id, hght, mth) oslg.log(CN.WRN, m1) oslg.log(CN.WRN, m2) - sub["height"] = height + sub["height"] = hght elif "head" in sub:# no "sill" if "height" in sub: sill = sub["head"] - sub["height"] if sill < min_sill - CN.TOL2: - sill = min_sill - height = sub["head"] - sill + sill = min_sill + hght = sub["head"] - sill - if height < glass: + if hght < glass: sub["count" ] = 0 sub["multiplier"] = 0 @@ -5825,12 +5825,12 @@ def addSubs(s=None, subs=[], clear=False, bound=False, realign=False, bfr=0.005) oslg.log(CN.ERR, m) continue else: - m = "(Re)set '%s' height %.3fm (%s)" % (id, sub["height"], mth) - log(CN.WRN, m) + m = "Reset '%s' height %.3fm (%s)" % (id, sub["height"], mth) + oslg.log(CN.WRN, m) sub["sill" ] = sill - sub["height"] = height + sub["height"] = hght m = "Height '%s' re(set) %.3fm (%s)" % (id, sub["height"], mth) - log(CN.WRN, m) + oslg.log(CN.WRN, m) else: sub["sill"] = sill else: @@ -5842,10 +5842,10 @@ def addSubs(s=None, subs=[], clear=False, bound=False, realign=False, bfr=0.005) head = sub["sill"] + sub["height"] if head > max_head - CN.TOL2: - head = max_head - height = head - sub["sill"] + head = max_head + hght = head - sub["sill"] - if height < glass: + if hght < glass: sub["count" ] = 0 sub["multiplier"] = 0 @@ -5857,10 +5857,10 @@ def addSubs(s=None, subs=[], clear=False, bound=False, realign=False, bfr=0.005) oslg.log(CN.ERR, m) continue else: - m = "(Re)set '%s' height %.3fm (%s)" % (id, sub["height"], mth) + m = "Reset '%s' height %.3fm (%s)" % (id, sub["height"], mth) oslg.log(CN.WRN, m) sub["head" ] = head - sub["height"] = height + sub["height"] = hght m = "Height '%s' reset to %.3fm (%s)" % (id, sub["height"], mth) oslg.log(CN.WRN, m) else: @@ -5890,7 +5890,7 @@ def addSubs(s=None, subs=[], clear=False, bound=False, realign=False, bfr=0.005) if (sub["width"] < glass - CN.TOL2 or sub["width"] > max_width + CN.TOL2): - m = "(Re)set '%s' width %.3fm (%s)" % (id, sub["width"], mth) + m = "Reset '%s' width %.3fm (%s)" % (id, sub["width"], mth) oslg.log(CN.WRN, m) sub["width"] = numpy.clip(sub["width"], glass, max_width) m = "Width '%s' reset to %.3fm ()%s)" % (id, sub["width"], mth) @@ -5909,20 +5909,20 @@ def addSubs(s=None, subs=[], clear=False, bound=False, realign=False, bfr=0.005) # Log/reset if left-sided buffer under min jamb position. if "l_buffer" in sub: if sub["l_buffer"] < min_ljamb - CN.TOL: - m = "(Re)set '%s' left buffer %.3fm (%s)" % (id, sub["l_buffer"], mth) - log(WRN, m) + m = "Reset '%s' left buffer %.3fm (%s)" % (id, sub["l_buffer"], mth) + oslg.log(WRN, m) sub["l_buffer"] = min_ljamb m = "Left buffer '%s' reset to %.3fm (%s)" % (id, sub["l_buffer"], mth) - log(WRN, m) + oslg.log(WRN, m) # Log/reset if right-sided buffer beyond max jamb position. if "r_buffer" in sub: if sub["r_buffer"] > max_rjamb - CN.TOL: - m = "(Re)set '%s' right buffer %.3fm (%s)" % (id, sub["r_buffer"], mth) - log(CN.WRN, m) + m = "Reset '%s' right buffer %.3fm (%s)" % (id, sub["r_buffer"], mth) + oslg.log(CN.WRN, m) sub["r_buffer"] = min_rjamb m = "Right buffer '%s' reset to %.3fm (%s)" % (id, sub["r_buffer"], mth) - log(CN.WRN, m) + oslg.log(CN.WRN, m) centre = mid_x if "centreline" in sub: centre += sub["centreline"] @@ -5948,7 +5948,7 @@ def addSubs(s=None, subs=[], clear=False, bound=False, realign=False, bfr=0.005) # Log/reset if "ratio" beyond min/max? if sub["ratio"] < min and sub["ratio"] > max: - m = "(Re)set ratio %.3f (%s)" % (sub["ratio"], mth) + m = "Reset ratio %.3f (%s)" % (sub["ratio"], mth) oslg.log(CN.WRN, m) sub["ratio"] = numpy.clip(sub["ratio"], min, max) m = "Ratio reset to %.3f (%s)" % (sub["ratio"], mth) @@ -5959,11 +5959,11 @@ def addSubs(s=None, subs=[], clear=False, bound=False, realign=False, bfr=0.005) sub["count"] = 1 oslg.log(CN.WRN, "Count (ratio) reset to 1 (%s)" % mth) - area = s.grossArea() * sub["ratio"] # sub m2, incl. frames - w = area / h - width = w - frames - x0 = centre - w/2 - xf = centre + w/2 + area = s.grossArea() * sub["ratio"] # sub m2, incl. frames + w = area / h + wdth = w - frames + x0 = centre - w/2 + xf = centre + w/2 if "l_buffer" in sub: if "centreline" in sub: @@ -5987,22 +5987,22 @@ def addSubs(s=None, subs=[], clear=False, bound=False, realign=False, bfr=0.005) sub["count" ] = 0 sub["multiplier"] = 0 - if "ratio" in sub: sub[ratio ] = 0 - if "height" in sub: sub[height] = 0 - if "width" in sub: sub[width ] = 0 + if "ratio" in sub: sub["ratio" ] = 0 + if "height" in sub: sub["height"] = 0 + if "width" in sub: sub["width" ] = 0 m = "Skip '%s': invalid (ratio) width/centreline (%s)" % (id, mth) oslg.log(CN.ERR, m) continue - if "width" in sub and abs(sub["width"] - width) > CN.TOL: - m = "(Re)set '%s' width (ratio) %.3fm (%s)" % (id, sub["width"], mth) + if "width" in sub and abs(sub["width"] - wdth) > CN.TOL: + m = "Reset '%s' width (ratio) %.3fm (%s)" % (id, sub["width"], mth) oslg.log(CN.WRN, m) - sub["width"] = width + sub["width"] = wdth m = "Width (ratio) '%s' reset to %.3fm (%s)" % (id, sub["width"], mth) oslg.log(CN.WRN, m) - if "width" not in sub: sub["width"] = width + if "width" not in sub: sub["width"] = wdth else: if "width" not in sub: @@ -6016,25 +6016,25 @@ def addSubs(s=None, subs=[], clear=False, bound=False, realign=False, bfr=0.005) oslg.log(CN.ERR, "Skip: missing '%s' width (%s})" % (id, mth)) continue - width = sub["width"] + frames - gap = (max_x - n * width) / (n + 1) + wdth = sub["width"] + frames + gap = (max_x - n * wdth) / (n + 1) - if "offset" in sub: gap = sub["offset"] - width + if "offset" in sub: gap = sub["offset"] - wdth if gap < buffer: gap = 0 - offset = gap + width + offset = gap + wdth if "offset" in sub and abs(offset - sub["offset"]) > CN.TOL: - m = "(Re)set '%s' sub offset %.3fm (%s)" % (id, sub["offset"], mth) - log(CN.WRN, m) + m = "Reset '%s' sub offset %.3fm (%s)" % (id, sub["offset"], mth) + oslg.log(CN.WRN, m) sub["offset"] = offset m = "Sub offset (%s) reset to %.3fm (%s)" % (id, sub["offset"], mth) - log(CN.WRN, m) + oslg.log(CN.WRN, m) - if "offset" not in sub.key: sub["offset"] = offset + if "offset" not in sub: sub["offset"] = offset # Overall width (including frames) of bounding box around array. - w = n * width + (n - 1) * gap + w = n * wdth + (n - 1) * gap x0 = centre - w/2 xf = centre + w/2 @@ -6049,7 +6049,7 @@ def addSubs(s=None, subs=[], clear=False, bound=False, realign=False, bfr=0.005) elif "r_buffer" in sub: if "centreline" in sub: m = "Skip '%s' right buffer (vs centreline) (%s)" % (id, mth) - log(WRN, m) + oslg.log(WRN, m) else: xf = max_x - sub["r_buffer"] + frame x0 = xf - w @@ -6057,8 +6057,8 @@ def addSubs(s=None, subs=[], clear=False, bound=False, realign=False, bfr=0.005) # Too wide? if x0 < buffer - CN.TOL2 or xf > max_x - buffer - CN.TOL2: - sub[:count ] = 0 - sub[:multiplier] = 0 + sub["count" ] = 0 + sub["multiplier"] = 0 if "ratio" in sub: sub["ratio" ] = 0 if "height" in sub: sub["height"] = 0 if "width" in sub: sub["width" ] = 0 @@ -6099,7 +6099,7 @@ def addSubs(s=None, subs=[], clear=False, bound=False, realign=False, bfr=0.005) if overlapping(vc, vk): nome = sb.nameString() - m = "Skip '%s': overlaps '%s' (%s)" % (name, nome, mth) + m = "Skip '%s': overlaps '%s' (%s)" % (name, nome, mth) oslg.log(CN.ERR, m) conflict = True break @@ -6118,7 +6118,7 @@ def addSubs(s=None, subs=[], clear=False, bound=False, realign=False, bfr=0.005) sb.setSurface(s) # Reset "pos" if array. - if offset in sub: pos += sub["offset"] + if "offset" in sub: pos += sub["offset"] return True diff --git a/tests/test_osut.py b/tests/test_osut.py index 008376e..d1fb604 100644 --- a/tests/test_osut.py +++ b/tests/test_osut.py @@ -48,7 +48,7 @@ def test00_oslg_constants(self): def test01_osm_instantiation(self): model = openstudio.model.Model() self.assertTrue(isinstance(model, openstudio.model.Model)) - del(model) + del model def test02_tuples(self): self.assertEqual(len(osut.sidz()), 6) @@ -90,7 +90,6 @@ def test05_construction_generation(self): self.assertEqual(o.status(), 0) self.assertEqual(o.reset(DBG), DBG) self.assertEqual(o.level(), DBG) - self.assertEqual(o.status(), 0) # Unsuccessful try: 2nd argument not a 'dict' (see 'm1'). model = openstudio.model.Model() @@ -102,7 +101,7 @@ def test05_construction_generation(self): self.assertEqual(o.clean(), DBG) self.assertFalse(o.logs()) self.assertEqual(o.status(), 0) - del(model) + del model # Unsuccessful try: 1st argument not a model (see 'm2'). model = openstudio.model.Model() @@ -114,7 +113,7 @@ def test05_construction_generation(self): self.assertEqual(o.clean(), DBG) self.assertFalse(o.logs()) self.assertEqual(o.status(), 0) - del(model) + del model # Defaulted specs (2nd argument). specs = dict() @@ -136,7 +135,7 @@ def test05_construction_generation(self): self.assertAlmostEqual(r, 1/u, places=3) self.assertFalse(o.logs()) self.assertEqual(o.status(), 0) - del(model) + del model # Typical uninsulated, framed cavity wall - suitable for light # interzone assemblies (i.e. symmetrical, 3-layer construction). @@ -157,7 +156,7 @@ def test05_construction_generation(self): self.assertEqual(specs["uo"], None) self.assertFalse(o.logs()) self.assertEqual(o.status(), 0) - del(model) + del model # Alternative to (uninsulated) partition (more inputs, same outcome). specs = dict(type="wall", clad="none", uo=None) @@ -177,7 +176,7 @@ def test05_construction_generation(self): self.assertEqual(specs["uo"], None) self.assertFalse(o.logs()) self.assertEqual(o.status(), 0) - del(model) + del model # Insulated partition variant. specs = dict(type="partition", uo=0.214) @@ -199,7 +198,7 @@ def test05_construction_generation(self): self.assertAlmostEqual(r, 1/specs["uo"], places=3) self.assertFalse(o.logs()) self.assertEqual(o.status(), 0) - del(model) + del model # Alternative to (insulated) partition (more inputs, similar outcome). specs = dict(type="wall", uo=0.214, clad="none") @@ -221,7 +220,7 @@ def test05_construction_generation(self): self.assertAlmostEqual(r, 1/specs["uo"], places=3) self.assertFalse(o.logs()) self.assertEqual(o.status(), 0) - del(model) + del model # A wall inherits a 4th (cladding) layer, by default. specs = dict(type="wall", uo=0.214) @@ -244,7 +243,7 @@ def test05_construction_generation(self): self.assertAlmostEqual(r, 1/specs["uo"], places=3) self.assertFalse(o.logs()) self.assertEqual(o.status(), 0) - del(model) + del model # Otherwise, a wall has a minimum of 2 layers. specs = dict(type="wall", uo=0.214, clad="none", finish="none") @@ -265,7 +264,7 @@ def test05_construction_generation(self): self.assertAlmostEqual(r, 1/specs["uo"], places=3) self.assertFalse(o.logs()) self.assertEqual(o.status(), 0) - del(model) + del model # Default shading material. specs = dict(type="shading") @@ -281,7 +280,7 @@ def test05_construction_generation(self): self.assertEqual(c.layers()[0].nameString(), "OSut.material.015") self.assertFalse(o.logs()) self.assertEqual(o.status(), 0) - del(model) + del model # A single-layered, 5/8" partition (alternative: "shading"). specs = dict(type="partition", clad="none", finish="none") @@ -297,7 +296,7 @@ def test05_construction_generation(self): self.assertEqual(c.layers()[0].nameString(), "OSut.material.015") self.assertFalse(o.logs()) self.assertEqual(o.status(), 0) - del(model) + del model # A single-layered 4" concrete partition. specs = dict(type="partition", clad="none", finish="none", frame="medium") @@ -313,7 +312,7 @@ def test05_construction_generation(self): self.assertEqual(c.layers()[0].nameString(), "OSut.concrete.100") self.assertFalse(o.logs()) self.assertEqual(o.status(), 0) - del(model) + del model # A single-layered 8" concrete partition. specs = dict(type="partition", clad="none", finish="none", frame="heavy") @@ -329,7 +328,7 @@ def test05_construction_generation(self): self.assertEqual(c.layers()[0].nameString(), "OSut.concrete.200") self.assertFalse(o.logs()) self.assertEqual(o.status(), 0) - del(model) + del model # A light (1x layer), uninsulated attic roof (alternative: "shading"). specs = dict(type="roof", uo=None, clad="none", finish="none") @@ -345,7 +344,7 @@ def test05_construction_generation(self): self.assertEqual(c.layers()[0].nameString(), "OSut.material.015") self.assertFalse(o.logs()) self.assertEqual(o.status(), 0) - del(model) + del model # Insulated, cathredral ceiling construction. specs = dict(type="roof", uo=0.214) @@ -367,7 +366,7 @@ def test05_construction_generation(self): self.assertAlmostEqual(r, 1/specs["uo"], places=3) self.assertFalse(o.logs()) self.assertEqual(o.status(), 0) - del(model) + del model # Insulated, unfinished outdoor-facing plenum roof (polyiso + 4" slab). specs = dict(type="roof", uo=0.214, frame="medium", finish="medium") @@ -389,7 +388,7 @@ def test05_construction_generation(self): self.assertAlmostEqual(r, 1/specs["uo"], places=3) self.assertFalse(o.logs()) self.assertEqual(o.status(), 0) - del(model) + del model # Insulated (conditioned), parking garage roof (polyiso under 8" slab). specs = dict(type="roof", uo=0.214, clad="heavy", frame="medium", finish="none") @@ -410,7 +409,7 @@ def test05_construction_generation(self): self.assertAlmostEqual(r, 1/specs["uo"], places=3) self.assertFalse(o.logs()) self.assertEqual(o.status(), 0) - del(model) + del model # Uninsulated plenum ceiling tiles (alternative: "shading"). specs = dict(type="roof", uo=None, clad="none", finish="none") @@ -426,7 +425,7 @@ def test05_construction_generation(self): self.assertEqual(c.layers()[0].nameString(), "OSut.material.015") self.assertFalse(o.logs()) self.assertEqual(o.status(), 0) - del(model) + del model # Unfinished, insulated, framed attic floor (blown cellulose). specs = dict(type="floor", uo=0.214, frame="heavy", finish="none") @@ -447,7 +446,7 @@ def test05_construction_generation(self): self.assertAlmostEqual(r, 1/0.214, places=3) self.assertFalse(o.logs()) self.assertEqual(o.status(), 0) - del(model) + del model # Finished, insulated exposed floor (e.g. wood-framed, residential). specs = dict(type="floor", uo=0.214) @@ -469,7 +468,7 @@ def test05_construction_generation(self): self.assertAlmostEqual(r, 1/specs["uo"], places=3) self.assertFalse(o.logs()) self.assertEqual(o.status(), 0) - del(model) + del model # Finished, insulated exposed floor (e.g. 4" slab, steel web joists). specs = dict(type="floor", uo=0.214, finish="medium") @@ -491,7 +490,7 @@ def test05_construction_generation(self): self.assertAlmostEqual(r, 1/specs["uo"], places=3) self.assertFalse(o.logs()) self.assertEqual(o.status(), 0) - del(model) + del model # Uninsulated slab-on-grade. specs = dict(type="slab", frame="none", finish="none") @@ -508,7 +507,7 @@ def test05_construction_generation(self): self.assertEqual(c.layers()[1].nameString(), "OSut.concrete.100") self.assertFalse(o.logs()) self.assertEqual(o.status(), 0) - del(model) + del model # Insulated slab-on-grade. specs = dict(type="slab", uo=0.214, finish="none") @@ -530,7 +529,7 @@ def test05_construction_generation(self): self.assertAlmostEqual(r, 1/specs["uo"], places=3) self.assertFalse(o.logs()) self.assertEqual(o.status(), 0) - del(model) + del model # 8" uninsulated basement wall. specs = dict(type="basement", clad="none", finish="none") @@ -546,7 +545,7 @@ def test05_construction_generation(self): self.assertEqual(c.layers()[0].nameString(), "OSut.concrete.200") self.assertFalse(o.logs()) self.assertEqual(o.status(), 0) - del(model) + del model # 8" interior-insulated, finished basement wall. specs = dict(type="basement", uo=0.428, clad="none") @@ -568,7 +567,7 @@ def test05_construction_generation(self): self.assertAlmostEqual(r, 1/specs["uo"], places=3) self.assertFalse(o.logs()) self.assertEqual(o.status(), 0) - del(model) + del model # Standard, insulated steel door (default Uo = 1.8 W/K•m). specs = dict(type="door") @@ -588,7 +587,7 @@ def test05_construction_generation(self): self.assertAlmostEqual(r, 1/specs["uo"], places=3) self.assertFalse(o.logs()) self.assertEqual(o.status(), 0) - del(model) + del model # Better-insulated door, window & skylight. specs = dict(type="door", uo=0.900) @@ -608,7 +607,7 @@ def test05_construction_generation(self): self.assertAlmostEqual(r, 1/specs["uo"], places=3) self.assertFalse(o.logs()) self.assertEqual(o.status(), 0) - del(model) + del model specs = dict(type="window", uo=0.900, shgc=0.35) model = openstudio.model.Model() @@ -627,7 +626,7 @@ def test05_construction_generation(self): self.assertAlmostEqual(r, 1/specs["uo"], places=3) self.assertFalse(o.logs()) self.assertEqual(o.status(), 0) - del(model) + del model specs = dict(type="skylight", uo=0.900) model = openstudio.model.Model() @@ -646,14 +645,13 @@ def test05_construction_generation(self): self.assertAlmostEqual(r, 1/specs["uo"], places=3) self.assertFalse(o.logs()) self.assertEqual(o.status(), 0) - del(model) + del model def test06_internal_mass(self): o = osut.oslg self.assertEqual(o.status(), 0) self.assertEqual(o.reset(DBG), DBG) self.assertEqual(o.level(), DBG) - self.assertEqual(o.status(), 0) ratios = dict(entrance=0.10, lobby=0.30, meeting=1.00) model = openstudio.model.Model() @@ -724,13 +722,12 @@ def test06_internal_mass(self): if not material: material = m self.assertEqual(material, m) - del(model) + del model def test07_construction_thickness(self): o = osut.oslg self.assertEqual(o.status(), 0) self.assertEqual(o.level(), DBG) - self.assertEqual(o.status(), 0) version = int("".join(openstudio.openStudioVersion().split("."))) translator = openstudio.osversion.VersionTranslator() @@ -854,14 +851,13 @@ def test07_construction_thickness(self): self.assertEqual(o.status(), 0) self.assertFalse(o.logs()) - del(model) + del model def test08_holds_constructions(self): o = osut.oslg self.assertEqual(o.status(), 0) self.assertEqual(o.reset(DBG), DBG) self.assertEqual(o.level(), DBG) - self.assertEqual(o.status(), 0) version = int("".join(openstudio.openStudioVersion().split("."))) translator = openstudio.osversion.VersionTranslator() @@ -956,15 +952,14 @@ def test08_holds_constructions(self): self.assertTrue(m3 in o.logs()[0]["message"]) self.assertEqual(o.clean(), DBG) - del(model) - del(mdl) + del model + del mdl def test09_construction_set(self): o = osut.oslg self.assertEqual(o.status(), 0) self.assertEqual(o.reset(DBG), DBG) self.assertEqual(o.level(), DBG) - self.assertEqual(o.status(), 0) version = int("".join(openstudio.openStudioVersion().split("."))) translator = openstudio.osversion.VersionTranslator() @@ -982,7 +977,7 @@ def test09_construction_set(self): self.assertTrue(set) self.assertEqual(o.status(), 0) - del(model) + del model # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # path = openstudio.path("./tests/files/osms/out/seb2.osm") @@ -999,14 +994,13 @@ def test09_construction_set(self): self.assertEqual(o.clean(), DBG) - del(model) + del model def test10_glazing_airfilms(self): o = osut.oslg self.assertEqual(o.status(), 0) self.assertEqual(o.reset(DBG), DBG) self.assertEqual(o.level(), DBG) - self.assertEqual(o.status(), 0) version = int("".join(openstudio.openStudioVersion().split("."))) translator = openstudio.osversion.VersionTranslator() @@ -1093,14 +1087,13 @@ def test10_glazing_airfilms(self): # 1c6fe48c49987c16e95e90ee3bd088ad0649ab9c/src/model/ # PlanarSurface.cpp#L878 - del(model) + del model def test11_rsi(self): o = osut.oslg self.assertEqual(o.status(), 0) self.assertEqual(o.reset(DBG), DBG) self.assertEqual(o.level(), DBG) - self.assertEqual(o.status(), 0) version = int("".join(openstudio.openStudioVersion().split("."))) translator = openstudio.osversion.VersionTranslator() @@ -1182,14 +1175,13 @@ def test11_rsi(self): self.assertEqual(o.logs()[0]["message"], m6) self.assertEqual(o.clean(), DBG) - del(model) + del model def test12_insulating_layer(self): o = osut.oslg self.assertEqual(o.status(), 0) self.assertEqual(o.reset(DBG), DBG) self.assertEqual(o.level(), DBG) - self.assertEqual(o.status(), 0) version = int("".join(openstudio.openStudioVersion().split("."))) translator = openstudio.osversion.VersionTranslator() @@ -1270,14 +1262,13 @@ def test12_insulating_layer(self): self.assertTrue(m0 in o.logs()[0]["message"]) self.assertEqual(o.clean(), DBG) - del(model) + del model def test13_spandrels(self): o = osut.oslg self.assertEqual(o.status(), 0) self.assertEqual(o.reset(DBG), DBG) self.assertEqual(o.level(), DBG) - self.assertEqual(o.status(), 0) # version = int("".join(openstudio.openStudioVersion().split("."))) translator = openstudio.osversion.VersionTranslator() @@ -1331,14 +1322,13 @@ def test13_spandrels(self): self.assertEqual(o.status(), 0) - del(model) + del model def test14_schedule_ruleset_minmax(self): o = osut.oslg self.assertEqual(o.status(), 0) self.assertEqual(o.reset(DBG), DBG) self.assertEqual(o.level(), DBG) - self.assertEqual(o.status(), 0) version = int("".join(openstudio.openStudioVersion().split("."))) translator = openstudio.osversion.VersionTranslator() @@ -1413,14 +1403,13 @@ def test14_schedule_ruleset_minmax(self): self.assertEqual(o.logs()[0]["message"], m3) self.assertEqual(o.clean(), DBG) - del(model) + del model def test15_schedule_constant_minmax(self): o = osut.oslg self.assertEqual(o.status(), 0) self.assertEqual(o.reset(DBG), DBG) self.assertEqual(o.level(), DBG) - self.assertEqual(o.status(), 0) version = int("".join(openstudio.openStudioVersion().split("."))) translator = openstudio.osversion.VersionTranslator() @@ -1495,14 +1484,13 @@ def test15_schedule_constant_minmax(self): self.assertEqual(o.logs()[0]["message"], m3) self.assertEqual(o.clean(), DBG) - del(model) + del model def test16_schedule_compact_minmax(self): o = osut.oslg self.assertEqual(o.status(), 0) self.assertEqual(o.reset(DBG), DBG) self.assertEqual(o.level(), DBG) - self.assertEqual(o.status(), 0) version = int("".join(openstudio.openStudioVersion().split("."))) translator = openstudio.osversion.VersionTranslator() @@ -1575,14 +1563,13 @@ def test16_schedule_compact_minmax(self): self.assertEqual(o.logs()[0]["message"], m3) self.assertEqual(o.clean(), DBG) - del(model) + del model def test17_minmax_heatcool_setpoints(self): o = osut.oslg self.assertEqual(o.status(), 0) self.assertEqual(o.reset(DBG), DBG) self.assertEqual(o.level(), DBG) - self.assertEqual(o.status(), 0) version = int("".join(openstudio.openStudioVersion().split("."))) translator = openstudio.osversion.VersionTranslator() @@ -1746,14 +1733,13 @@ def test17_minmax_heatcool_setpoints(self): self.assertTrue(stpts["heating"]) self.assertAlmostEqual(stpts["heating"], 22.78, places=2) - del(model) + del model def test18_hvac_airloops(self): o = osut.oslg self.assertEqual(o.status(), 0) self.assertEqual(o.reset(DBG), DBG) self.assertEqual(o.level(), DBG) - self.assertEqual(o.status(), 0) msg = "'model' str? expecting Model (osut.hasAirLoopsHVAC)" version = int("".join(openstudio.openStudioVersion().split("."))) @@ -1774,7 +1760,7 @@ def test18_hvac_airloops(self): self.assertEqual(o.logs()[0]["message"], msg) self.assertEqual(o.clean(), DBG) - del(model) + del model # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # path = openstudio.path("./tests/files/osms/in/5ZoneNoHVAC.osm") @@ -1791,14 +1777,13 @@ def test18_hvac_airloops(self): self.assertEqual(o.logs()[0]["message"], msg) self.assertEqual(o.clean(), DBG) - del(model) + del model def test19_vestibules(self): o = osut.oslg self.assertEqual(o.status(), 0) self.assertEqual(o.reset(DBG), DBG) self.assertEqual(o.level(), DBG) - self.assertEqual(o.status(), 0) translator = openstudio.osversion.VersionTranslator() @@ -1862,14 +1847,13 @@ def test19_vestibules(self): self.assertTrue(entry.additionalProperties().resetFeature(tag)) self.assertEqual(o.status(), 0) - del(model) + del model def test20_setpoints_plenums_attics(self): o = osut.oslg self.assertEqual(o.status(), 0) self.assertEqual(o.reset(DBG), DBG) self.assertEqual(o.level(), DBG) - self.assertEqual(o.status(), 0) cl1 = openstudio.model.Space cl2 = openstudio.model.Model @@ -1979,7 +1963,7 @@ def test20_setpoints_plenums_attics(self): self.assertFalse(osut.setpoints(plenum)["cooling"]) self.assertEqual(o.status(), 0) - del(model) + del model # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # path = openstudio.path("./tests/files/osms/in/warehouse.osm") @@ -1994,7 +1978,7 @@ def test20_setpoints_plenums_attics(self): self.assertFalse(osut.isRefrigerated(space)) self.assertFalse(osut.isSemiheated(space)) - del(model) + del model # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # path = openstudio.path("./tests/files/osms/in/smalloffice.osm") @@ -2094,7 +2078,7 @@ def test20_setpoints_plenums_attics(self): self.assertEqual(cnd.get(), val) self.assertEqual(o.status(), 0) - del(model) + del model # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # # Consider adding LargeOffice model to test SDK's "isPlenum" ... @todo @@ -2103,7 +2087,6 @@ def test21_availability_schedules(self): self.assertEqual(o.status(), 0) self.assertEqual(o.reset(DBG), DBG) self.assertEqual(o.level(), DBG) - self.assertEqual(o.status(), 0) v = int("".join(openstudio.openStudioVersion().split("."))) translator = openstudio.osversion.VersionTranslator() @@ -2311,14 +2294,13 @@ def test21_availability_schedules(self): self.assertEqual(int(day_schedule.getValue(am01)), 0) self.assertEqual(int(day_schedule.getValue(pm11)), 0) - del(model) + del model def test22_model_transformation(self): o = osut.oslg self.assertEqual(o.status(), 0) self.assertEqual(o.reset(DBG), DBG) self.assertEqual(o.level(), DBG) - self.assertEqual(o.status(), 0) translator = openstudio.osversion.VersionTranslator() # Successful test. @@ -2648,7 +2630,6 @@ def test23_fits_overlaps(self): self.assertEqual(o.status(), 0) self.assertEqual(o.reset(DBG), DBG) self.assertEqual(o.level(), DBG) - self.assertEqual(o.status(), 0) v = int("".join(openstudio.openStudioVersion().split("."))) @@ -2850,7 +2831,7 @@ def test23_fits_overlaps(self): self.assertTrue(osut.fits(wall, glazing)) self.assertTrue(osut.overlapping(wall, glazing)) - del(model) + del model self.assertEqual(o.clean(), DBG) # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # @@ -3038,7 +3019,7 @@ def test23_fits_overlaps(self): self.assertEqual(int(100 * area2 / area1), 68) # % self.assertEqual(o.status(), 0) - del(model) + del model # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # # Testing more complex cases, e.g. triangular windows, irregular 4-side @@ -3205,7 +3186,7 @@ def test23_fits_overlaps(self): self.assertFalse(osut.overlapping(w1o, w3o)) self.assertFalse(osut.overlapping(w2o, w3o)) - del(model) + del model self.assertEqual(o.clean(), DBG) def test24_triangulation(self): @@ -3213,7 +3194,6 @@ def test24_triangulation(self): self.assertEqual(o.status(), 0) self.assertEqual(o.reset(DBG), DBG) self.assertEqual(o.level(), DBG) - self.assertEqual(o.status(), 0) holes = openstudio.Point3dVectorVector() @@ -3256,7 +3236,6 @@ def test25_segments_triads_orientation(self): self.assertEqual(o.status(), 0) self.assertEqual(o.reset(DBG), DBG) self.assertEqual(o.level(), DBG) - self.assertEqual(o.status(), 0) # Enclosed polygon. p0 = openstudio.Point3d(-5, -5, -5) @@ -3457,7 +3436,6 @@ def test26_ulc_blc(self): self.assertEqual(o.status(), 0) self.assertEqual(o.reset(DBG), DBG) self.assertEqual(o.level(), DBG) - self.assertEqual(o.status(), 0) # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # # Regular polygon, counterclockwise yet not UpperLeftCorner (ULC). @@ -3631,7 +3609,6 @@ def test27_polygon_attributes(self): self.assertEqual(o.status(), 0) self.assertEqual(o.reset(INF), INF) self.assertEqual(o.level(), INF) - self.assertEqual(o.status(), 0) # 2x points (not a polygon). vtx = openstudio.Point3dVector() @@ -3849,9 +3826,8 @@ def test28_subsurface_insertions(self): # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # o = osut.oslg self.assertEqual(o.status(), 0) - self.assertEqual(o.reset(INF), INF) - self.assertEqual(o.level(), INF) - self.assertEqual(o.status(), 0) + self.assertEqual(o.reset(DBG), DBG) + self.assertEqual(o.level(), DBG) translator = openstudio.osversion.VersionTranslator() v = int("".join(openstudio.openStudioVersion().split("."))) @@ -3896,7 +3872,6 @@ def test28_subsurface_insertions(self): roof_right = openstudio.Point3d(-5.4769, 11.2626, 2.3528) length = (roof_left - roof_right).length() - # New slanted roof. vec = openstudio.Point3dVector() vec.append(w5_0) @@ -4018,7 +3993,7 @@ def test28_subsurface_insertions(self): # buffer under 10mm, see: https://rd2.github.io/tbd/pages/subs.html. y = max_y - 0.005 - x = centerline - width / 2 # center window + x = centerline - width / 2 # center window vec = openstudio.Point3dVector() vec.append(openstudio.Point3d(x, y, 0)) vec.append(openstudio.Point3d(x, y - height, 0)) @@ -4030,7 +4005,7 @@ def test28_subsurface_insertions(self): self.assertTrue(tilt_window1.setSubSurfaceType("FixedWindow")) self.assertTrue(tilt_window1.setSurface(tilt_wall)) - x = centerline - 3*width/2 - 0.15 # window to the left of the first one + x = centerline - 3*width/2 - 0.15 # window to the left of the first one vec = openstudio.Point3dVector() vec.append(openstudio.Point3d(x, y, 0)) vec.append(openstudio.Point3d(x, y - height, 0)) @@ -4042,7 +4017,7 @@ def test28_subsurface_insertions(self): self.assertTrue(tilt_window2.setSubSurfaceType("FixedWindow")) self.assertTrue(tilt_window2.setSurface(tilt_wall)) - x = centerline + width/2 + 0.15 # window to the right of the first one + x = centerline + width/2 + 0.15 # window to the right of the first one vec = openstudio.Point3dVector() vec.append(openstudio.Point3d(x, y, 0)) vec.append(openstudio.Point3d(x, y - height, 0)) @@ -4126,11 +4101,70 @@ def test28_subsurface_insertions(self): model.save("./tests/files/osms/out/seb_ext1.osm", True) # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # - # Now test the same result when relying on OSut::addSub TO DO. + # Now test the same result when relying on osut.addSub: + path = openstudio.path("./tests/files/osms/out/seb_mod.osm") + model = translator.loadModel(path) + self.assertTrue(model) + model = model.get() + + roof = model.getSurfaceByName("Openarea slanted roof") + self.assertTrue(roof) + roof = roof.get() - del(model) + tilt_wall = model.getSurfaceByName("Openarea tilted wall") + self.assertTrue(tilt_wall) + tilt_wall = tilt_wall.get() + + head = max_y - 0.005 + offset = width + 0.15 + + # Add array of 3x windows to tilted wall. + sub = {} + sub["id" ] = "Tilted window" + sub["height"] = height + sub["width" ] = width + sub["head" ] = head + sub["count" ] = 3 + sub["offset"] = offset + + # The simplest argument set for 'addSubs' is: + self.assertTrue(osut.addSubs(tilt_wall, sub)) self.assertEqual(o.status(), 0) + # As the base surface is tilted, OpenStudio's 'alignFace' and + # 'alignZPrime' behave in a very intuitive manner: there is no point + # requesting 'addSubs' first realigns and/or concentrates on the + # polygon's bounded box - the outcome would be the same in all cases: + # + # self.assertTrue(osut.addSubs(tilt_wall, sub, False, False, True)) + # self.assertTrue(osut.addSubs(tilt_wall, sub, False, True)) + tilted = model.getSubSurfaceByName("Tilted window:0") + self.assertTrue(tilted) + tilted = tilted.get() + + construction = tilted.construction() + self.assertTrue(construction) + construction = construction.get() + sub["assembly"] = construction + + del sub["head"] + self.assertFalse("head" in sub) + sub["id" ] = "" + sub["sill"] = 0.0 # will be reset to 5mm + sub["type"] = "Skylight" + self.assertTrue(osut.addSubs(roof, sub)) + self.assertTrue(o.is_warn()) + self.assertEqual(len(o.logs()), 2) + + for lg in o.logs(): + self.assertTrue("reset" in lg["message"].lower()) + self.assertTrue("sill" in lg["message"].lower()) + + model.save("./tests/files/osms/out/seb_ext2.osm", True) + + del model + self.assertEqual(o.clean(), DBG) + # def test29_surface_width_height(self): # def test30_wwr_insertions(self): @@ -4140,7 +4174,7 @@ def test31_convexity(self): self.assertEqual(o.status(), 0) self.assertEqual(o.reset(INF), INF) self.assertEqual(o.level(), INF) - self.assertEqual(o.status(), 0) + translator = openstudio.osversion.VersionTranslator() version = int("".join(openstudio.openStudioVersion().split("."))) @@ -4318,7 +4352,7 @@ def test31_convexity(self): head = osut.height(aligned) - 0.2 self.assertAlmostEqual(head, 10.16, places=2) - del(model) + del model self.assertEqual(o.status(), 0) def test32_outdoor_roofs(self): @@ -4326,8 +4360,6 @@ def test32_outdoor_roofs(self): self.assertEqual(o.status(), 0) self.assertEqual(o.reset(INF), INF) self.assertEqual(o.level(), INF) - self.assertEqual(o.status(), 0) - translator = openstudio.osversion.VersionTranslator() path = openstudio.path("./tests/files/osms/in/5ZoneNoHVAC.osm") @@ -4368,7 +4400,7 @@ def test32_outdoor_roofs(self): self.assertTrue(id in roofs.keys()) self.assertEqual(roofs[id], surface) - del(model) + del model # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # # CASE 2: None of the occupied spaces have outdoor-facing roofs, yet @@ -4413,7 +4445,7 @@ def test32_outdoor_roofs(self): self.assertTrue(occ in roofs.keys()) self.assertTrue("plenum" in roofs[occ].lower()) - del(model) + del model self.assertEqual(o.status(), 0) # def test33_leader_line_anchors_inserts(self): @@ -4425,7 +4457,6 @@ def test35_facet_retrieval(self): self.assertEqual(o.status(), 0) self.assertEqual(o.reset(DBG), DBG) self.assertEqual(o.level(), DBG) - self.assertEqual(o.status(), 0) translator = openstudio.osversion.VersionTranslator() @@ -4492,14 +4523,13 @@ def test35_facet_retrieval(self): # Without arguments, the method returns ALL surfaces and subsurfaces. self.assertEqual(len(osut.facets(spaces)), len(surfs) + len(subs)) - del(model) + del model def test36_slab_generation(self): o = osut.oslg self.assertEqual(o.status(), 0) self.assertEqual(o.reset(DBG), DBG) self.assertEqual(o.level(), DBG) - self.assertEqual(o.status(), 0) model = openstudio.model.Model() x0 = 1 @@ -4681,7 +4711,7 @@ def test36_slab_generation(self): self.assertAlmostEqual(surface.grossArea(), 5 * 20 - 1, places=2) self.assertEqual(o.status(), 0) - del(model) + del model # def test37_roller_shades(self): From b3319057d44ecac95398f0cd86e80c66f682c5c9 Mon Sep 17 00:00:00 2001 From: brgix Date: Mon, 21 Jul 2025 07:54:42 -0400 Subject: [PATCH 33/49] Completes aligned width/height tests --- tests/test_osut.py | 89 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 88 insertions(+), 1 deletion(-) diff --git a/tests/test_osut.py b/tests/test_osut.py index d1fb604..ac8eeb0 100644 --- a/tests/test_osut.py +++ b/tests/test_osut.py @@ -4165,7 +4165,94 @@ def test28_subsurface_insertions(self): del model self.assertEqual(o.clean(), DBG) - # def test29_surface_width_height(self): + def test29_surface_width_height(self): + o = osut.oslg + self.assertEqual(o.status(), 0) + self.assertEqual(o.reset(DBG), DBG) + self.assertEqual(o.level(), DBG) + translator = openstudio.osversion.VersionTranslator() + + # Successful test. + path = openstudio.path("./tests/files/osms/out/seb_ext2.osm") + model = translator.loadModel(path) + self.assertTrue(model) + model = model.get() + + # Extension holds: + # - 2x vertical side walls + # - tilted (cantilevered) wall + # - sloped roof + tilted = model.getSurfaceByName("Openarea tilted wall") + left = model.getSurfaceByName("Openarea left side wall") + right = model.getSurfaceByName("Openarea right side wall") + self.assertTrue(tilted) + self.assertTrue(left) + self.assertTrue(right) + tilted = tilted.get() + left = left.get() + right = right.get() + + self.assertFalse(osut.facingUp(tilted)) + self.assertFalse(osut.shareXYZ(tilted)) + + # Neither wall has coordinates that align with the model grid. Without + # some transformation (eg alignFace), OSut's 'width' of a given surface + # is of limited utility. A vertical surface's 'height' is also somewhat + # valid/useful. + w1 = osut.width(tilted) + h1 = osut.height(tilted) + self.assertAlmostEqual(w1, 5.69, places=2) + self.assertAlmostEqual(h1, 2.35, places=2) + + # Aligned, a vertical or sloped (or tilted) surface's 'width' and + # 'height' correctly report what a tape measurement would reveal + # (from left to right, when looking at the surface perpendicularly). + t = openstudio.Transformation.alignFace(tilted.vertices()) + tilted_aligned = t.inverse() * tilted.vertices() + w01 = osut.width(tilted_aligned) + h01 = osut.height(tilted_aligned) + self.assertTrue(osut.facingUp(tilted_aligned)) + self.assertTrue(osut.shareXYZ(tilted_aligned)) + self.assertAlmostEqual(w01, 5.89, places=2) + self.assertAlmostEqual(h01, 3.09, places=2) + + w2 = osut.width(left) + h2 = osut.height(left) + self.assertAlmostEqual(w2, 0.45, places=2) + self.assertAlmostEqual(h2, 3.35, places=2) + t = openstudio.Transformation.alignFace(left.vertices()) + left_aligned = t.inverse() * left.vertices() + w02 = osut.width(left_aligned) + h02 = osut.height(left_aligned) + self.assertAlmostEqual(w02, 2.24, places=2) + self.assertAlmostEqual(h02, h2, places=2) # 'height' based on Y-axis (vs Z-axis) + + w3 = osut.width(right) + h3 = osut.height(right) + self.assertAlmostEqual(w3, 1.48, places=2) + self.assertAlmostEqual(h3, h2) # same as left + t = openstudio.Transformation.alignFace(right.vertices()) + right_aligned = t.inverse() * right.vertices() + w03 = osut.width(right_aligned) + h03 = osut.height(right_aligned) + self.assertAlmostEqual(w03, w02, places=2) # same as aligned left + self.assertAlmostEqual(h03, h02, places=2) # same as aligned left + + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + # What if wall vertex sequences were no longer ULC (e.g. URC)? + vec = openstudio.Point3dVector() + vec.append(tilted.vertices()[3]) + vec.append(tilted.vertices()[0]) + vec.append(tilted.vertices()[1]) + vec.append(tilted.vertices()[2]) + self.assertTrue(tilted.setVertices(vec)) + self.assertAlmostEqual(osut.width(tilted), w1, places=2) # same result + self.assertAlmostEqual(osut.height(tilted), h1, places=2) # same result + + model.save("./tests/files/osms/out/seb_ext4.osm", True) + + del model + self.assertEqual(o.status(), 0) # def test30_wwr_insertions(self): From 3f8ec9a6156fd00ee38ec6ede1d29d8eed404ebb Mon Sep 17 00:00:00 2001 From: brgix Date: Mon, 21 Jul 2025 15:15:44 -0400 Subject: [PATCH 34/49] Completes 'WWR insertion' tests --- src/osut/osut.py | 20 ++--- tests/test_osut.py | 216 ++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 225 insertions(+), 11 deletions(-) diff --git a/src/osut/osut.py b/src/osut/osut.py index 3fb57d7..230e405 100644 --- a/src/osut/osut.py +++ b/src/osut/osut.py @@ -4314,12 +4314,12 @@ def offset(p1=None, w=0, v=0) -> openstudio.Point3dVector: if v >= 340: t = openstudio.Transformation.alignFace(p1) - offset = openstudio.buffer(pts, w, CN.TOL) - if not offset: return p1 + offst = openstudio.buffer(pts, w, CN.TOL) + if not offst: return p1 - offset = offset.get() - offset.reverse() - return p3Dv(list(t * offset)) + offst = offst.get() + offst.reverse() + return p3Dv(list(t * offst)) else: # brute force approach pz = {} pz["A"] = {} @@ -6022,16 +6022,16 @@ def addSubs(s=None, subs=[], clear=False, bound=False, realign=False, bfr=0.005) if "offset" in sub: gap = sub["offset"] - wdth if gap < buffer: gap = 0 - offset = gap + wdth + offst = gap + wdth - if "offset" in sub and abs(offset - sub["offset"]) > CN.TOL: + if "offset" in sub and abs(offst - sub["offset"]) > CN.TOL: m = "Reset '%s' sub offset %.3fm (%s)" % (id, sub["offset"], mth) oslg.log(CN.WRN, m) - sub["offset"] = offset + sub["offset"] = offst m = "Sub offset (%s) reset to %.3fm (%s)" % (id, sub["offset"], mth) oslg.log(CN.WRN, m) - if "offset" not in sub: sub["offset"] = offset + if "offset" not in sub: sub["offset"] = offst # Overall width (including frames) of bounding box around array. w = n * wdth + (n - 1) * gap @@ -6081,7 +6081,7 @@ def addSubs(s=None, subs=[], clear=False, bound=False, realign=False, bfr=0.005) vec = t * (s00["r"] * (s00["t"] * vec)) if s00 else t * vec # Log/skip if conflict between individual sub and base surface. - vc = offset(vc, fr, 300) if fr > 0 else p3Dv(vec) + vc = offset(vec, fr, 300) if fr > 0 else p3Dv(vec) if not fits(vc, s): m = "Skip '%s': won't fit in '%s' (%s)" % (name, nom, mth) diff --git a/tests/test_osut.py b/tests/test_osut.py index ac8eeb0..b19843a 100644 --- a/tests/test_osut.py +++ b/tests/test_osut.py @@ -40,6 +40,8 @@ FTL = osut.CN.FTL TOL = osut.CN.TOL TOL2 = osut.CN.TOL2 +HEAD = osut.CN.HEAD +SILL = osut.CN.SILL class TestOSutModuleMethods(unittest.TestCase): def test00_oslg_constants(self): @@ -4254,7 +4256,219 @@ def test29_surface_width_height(self): del model self.assertEqual(o.status(), 0) - # def test30_wwr_insertions(self): + def test30_wwr_insertions(self): + o = osut.oslg + self.assertEqual(o.status(), 0) + self.assertEqual(o.reset(DBG), DBG) + self.assertEqual(o.level(), DBG) + translator = openstudio.osversion.VersionTranslator() + + path = openstudio.path("./tests/files/osms/out/seb_ext2.osm") + model = translator.loadModel(path) + self.assertTrue(model) + model = model.get() + wwr = 0.10 + + # Fetch "Openarea Wall 3". + wall3 = model.getSurfaceByName("Openarea 1 Wall 3") + self.assertTrue(wall3) + wall3 = wall3.get() + area = wall3.grossArea() * wwr + + # Fetch "Openarea Wall 4". + wall4 = model.getSurfaceByName("Openarea 1 Wall 4") + self.assertTrue(wall4) + wall4 = wall4.get() + + # Fetch transform if wall3 vertices were to 'align'. + tr = openstudio.Transformation.alignFace(wall3.vertices()) + a_wall3 = tr.inverse() * wall3.vertices() + ymax = max([pt.y() for pt in a_wall3]) + xmax = max([pt.x() for pt in a_wall3]) + xmid = xmax / 2 # centreline + + # Fetch 'head'/'sill' heights of nearby "Sub Surface 1". + sub1 = model.getSubSurfaceByName("Sub Surface 1") + self.assertTrue(sub1) + sub1 = sub1.get() + + sub1_min = min([pt.z() for pt in sub1.vertices()]) + sub1_max = max([pt.z() for pt in sub1.vertices()]) + + # Add 2x window strips, each representing 10% WWR of wall3 (20% total). + # - 1x constrained to sub1 'head' & 'sill' + # - 1x contrained only to 2nd 'sill' height + wwr1 = {} + wwr1["id" ] = "OA1 W3 wwr1|10" + wwr1["ratio"] = 0.1 + wwr1["head" ] = sub1_max + wwr1["sill" ] = sub1_min + + wwr2 = {} + wwr2["id" ] = "OA1 W3 wwr2|10" + wwr2["ratio"] = 0.1 + wwr2["sill" ] = wwr1["head"] + 0.1 + + sbz = [wwr1, wwr2] + self.assertTrue(osut.addSubs(wall3, sbz)) + self.assertEqual(o.status(), 0) + sbz = wall3.subSurfaces() + self.assertEqual(len(sbz), 2) + + for sb in sbz: + self.assertAlmostEqual(sb.grossArea(), area, places=2) + sb_sill = min([pt.z() for pt in sb.vertices()]) + sb_head = max([pt.z() for pt in sb.vertices()]) + + if "wwr1" in sb.nameString(): + self.assertAlmostEqual(sb_sill, wwr1["sill"], places=2) + self.assertAlmostEqual(sb_head, wwr1["head"], places=2) + self.assertNotEqual(sb_head, HEAD) + else: + self.assertAlmostEqual(sb_sill, wwr2["sill"], places=2) + self.assertAlmostEqual(sb_head, HEAD, places=2) # defaulted + + self.assertAlmostEqual(wall3.windowToWallRatio(), wwr * 2, places=2) + + # Fetch transform if wall4 vertices were to 'align'. + tr = openstudio.Transformation.alignFace(wall4.vertices()) + a_wall4 = tr.inverse() * wall4.vertices() + ymax = max([pt.y() for pt in a_wall4]) + xmax = max([pt.x() for pt in a_wall4]) + xmid = xmax / 2 # centreline + + # Add 4x sub surfaces (with frame & dividers) to wall4: + # 1. w1: 0.8m-wide opening (head defaulted to HEAD, sill @0m) + # 2. w2: 0.4m-wide sidelite, to the immediate right of w2 (HEAD, sill@0) + # 3. t1: 0.8m-wide transom above w1 (0.4m in height) + # 4. t2: 0.5m-wide transom above w2 (0.4m in height) + # + # All 4x sub surfaces are intended to share frame edges (once frame & + # divider frame widths are taken into account). Postulating a 50mm frame, + # meaning 100mm between w1, w2, t1 vs t2 vertices. In addition, all 4x + # openings (grouped together) should align towards the left of wall4, + # leaving a 200mm gap between the left vertical wall edge and the left + # frame jamb edge of w1 & t1. First initialize Frame & Divider object. + gap = 0.200 + frame = 0.050 + frames = 2 * frame + + fd = openstudio.model.WindowPropertyFrameAndDivider(model) + self.assertTrue(fd.setFrameWidth(frame)) + self.assertTrue(fd.setFrameConductance(2.500)) + + w1 = {} + w1["id" ] = "OA1 W4 w1" + w1["frame" ] = fd + w1["width" ] = 0.8 + w1["head" ] = HEAD + w1["sill" ] = 0.005 + frame # to avoid generating a warning + w1["centreline"] = -xmid + gap + frame + w1["width"]/2 + + w2 = {} + w2["id" ] = "OA1 W4 w2" + w2["frame" ] = fd + w2["width" ] = w1["width" ]/2 + w2["head" ] = w1["head" ] + w2["sill" ] = w1["sill" ] + w2["centreline"] = w1["centreline"] + w1["width"]/2 + frames + w2["width"]/2 + + t1 = {} + t1["id" ] = "OA1 W4 t1" + t1["frame" ] = fd + t1["width" ] = w1["width" ] + t1["height" ] = w2["width" ] + t1["sill" ] = w1["head" ] + frames + t1["centreline"] = w1["centreline"] + + t2 = {} + t2["id" ] = "OA1 W4 t2" + t2["frame" ] = fd + t2["width" ] = w2["width" ] + t2["height" ] = t1["height" ] + t2["sill" ] = t1["sill" ] + t2["centreline"] = w2["centreline"] + + sbz = [w1, w2, t1, t2] + self.assertTrue(osut.addSubs(wall4, sbz)) + if o.status() > 0: print(o.logs()) + self.assertEqual(o.status(), 0) + + # Add another 5x (frame÷r-enabled) fixed windows, from either + # left- or right-corner of base surfaces. Fetch "Openarea Wall 6". + wall6 = model.getSurfaceByName("Openarea 1 Wall 6") + self.assertTrue(wall6) + wall6 = wall6.get() + + # Fetch "Openarea Wall 7". + wall7 = model.getSurfaceByName("Openarea 1 Wall 7") + self.assertTrue(wall7) + wall7 = wall7.get() + + # Fetch 'head'/'sill' heights of nearby "Sub Surface 6". + sub6 = model.getSubSurfaceByName("Sub Surface 6") + self.assertTrue(sub6) + sub6 = sub6.get() + + sub6_min = min([pt.z() for pt in sub6.vertices()]) + sub6_max = max([pt.z() for pt in sub6.vertices()]) + + # 1x Array of 3x windows, 8" from the left corner of wall6. + a6 = {} + a6["id" ] = "OA1 W6 a6" + a6["count" ] = 3 + a6["frame" ] = fd + a6["head" ] = sub6_max + a6["sill" ] = sub6_min + a6["width" ] = a6["head" ] - a6["sill"] + a6["offset" ] = a6["width"] + gap + a6["l_buffer"] = gap + + self.assertTrue(osut.addSubs(wall6, a6)) + + # 1x Array of 2x square windows, 8" from the right corner of wall7. + a7 = {} + a7["id" ] = "OA1 W6 a7" + a7["count" ] = 2 + a7["frame" ] = fd + a7["head" ] = sub6_max + a7["sill" ] = sub6_min + a7["width" ] = a7["head" ] - a7["sill"] + a7["offset" ] = a7["width"] + gap + a7["r_buffer"] = gap + + self.assertTrue(osut.addSubs(wall7, a7)) + + model.save("./tests/files/osms/out/seb_ext3.osm", True) + + # Fetch a (flat) plenum roof surface, and add a single skylight. + id = "Level 0 Open area 1 ceiling Plenum RoofCeiling" + ruf1 = model.getSurfaceByName(id) + self.assertTrue(ruf1) + ruf1 = ruf1.get() + + construction = [cc for cc in model.getConstructions() if cc.isFenestration()] + self.assertEqual(len(construction), 1) + construction = construction[0] + + a8 = {} + a8["id" ] = "ruf skylight" + a8["type" ] = "Skylight" + a8["count" ] = 1 + a8["width" ] = 1.2 + a8["height" ] = 1.2 + a8["assembly"] = construction + + self.assertTrue(osut.addSubs(ruf1, a8)) + + # The plenum roof inherits a single skylight (without any skylight well). + # See "checks generated skylight wells": "seb_ext3a" vs "seb_sky" + # - more sensible alignment of skylight(s) wrt to roof geometry + # - automated skylight well generation + model.save("./tests/files/osms/out/seb_ext3a.osm", True) + + del model + self.assertEqual(o.status(), 0) def test31_convexity(self): o = osut.oslg From 765d3285685265a415b7e6a45667199c388e3fd8 Mon Sep 17 00:00:00 2001 From: brgix Date: Mon, 21 Jul 2025 15:39:18 -0400 Subject: [PATCH 35/49] Completes 'genShade' fixes/tests --- src/osut/osut.py | 50 ++++++++++++++++---------------- tests/test_osut.py | 71 +++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 95 insertions(+), 26 deletions(-) diff --git a/src/osut/osut.py b/src/osut/osut.py index 230e405..142f99f 100644 --- a/src/osut/osut.py +++ b/src/osut/osut.py @@ -677,12 +677,12 @@ def genShade(subs=None) -> bool: if onoff: onoff = onoff.get() else: - onoff = openstudio.model.ScheduleTypeLimits(model) - onoff.setName(id) - onoff.setLowerLimitValue(0) - onoff.setUpperLimitValue(1) - onoff.setNumericType("Discrete") - onoff.setUnitType("Availability") + onoff = openstudio.model.ScheduleTypeLimits(model) + onoff.setName(id) + onoff.setLowerLimitValue(0) + onoff.setUpperLimitValue(1) + onoff.setNumericType("Discrete") + onoff.setUnitType("Availability") # Shading schedule. id = "OSut.SHADE.Ruleset" @@ -691,10 +691,10 @@ def genShade(subs=None) -> bool: if sch: sch = sch.get() else: - sch = openstudio.model.ScheduleRuleset(model, 0) - sch.setName(id) - sch.setScheduleTypeLimits(onoff) - sch.defaultDaySchedule.setName("OSut.SHADE.Ruleset.Default") + sch = openstudio.model.ScheduleRuleset(model, 0) + sch.setName(id) + sch.setScheduleTypeLimits(onoff) + sch.defaultDaySchedule().setName("OSut.SHADE.Ruleset.Default") # Summer cooling rule. id = "OSut.SHADE.ScheduleRule" @@ -703,28 +703,28 @@ def genShade(subs=None) -> bool: if rule: rule = rule.get() else: - may = openstudio.MonthOfYear("May") - october = openstudio.MonthOfYear("Oct") - start = openstudio.Date(may, 1) - finish = openstudio.Date(october, 31) - - rule = openstudio.model.ScheduleRule(sch) - rule.setName(id) - rule.setStartDate(start) - rule.setEndDate(finish) - rule.setApplyAllDays(True) - rule.daySchedule.setName("OSut.SHADE.Rule.Default") - rule.daySchedule.addValue(openstudio.Time(0,24,0,0), 1) + may = openstudio.MonthOfYear("May") + october = openstudio.MonthOfYear("Oct") + start = openstudio.Date(may, 1) + finish = openstudio.Date(october, 31) + + rule = openstudio.model.ScheduleRule(sch) + rule.setName(id) + rule.setStartDate(start) + rule.setEndDate(finish) + rule.setApplyAllDays(True) + rule.daySchedule().setName("OSut.SHADE.Rule.Default") + rule.daySchedule().addValue(openstudio.Time(0,24,0,0), 1) # Shade object. id = "OSut.SHADE" - shd = mdl.getShadeByName(id) + shd = model.getShadeByName(id) if shd: shd = shd.get() else: - shd = openstudio.model.Shade(mdl) - shd.setName(id) + shd = openstudio.model.Shade(model) + shd.setName(id) # Shading control (unique to each call). id = "OSut.ShadingControl" diff --git a/tests/test_osut.py b/tests/test_osut.py index b19843a..e11a5a3 100644 --- a/tests/test_osut.py +++ b/tests/test_osut.py @@ -5014,7 +5014,76 @@ def test36_slab_generation(self): self.assertEqual(o.status(), 0) del model - # def test37_roller_shades(self): + def test37_roller_shades(self): + o = osut.oslg + self.assertEqual(o.status(), 0) + self.assertEqual(o.reset(DBG), DBG) + self.assertEqual(o.level(), DBG) + translator = openstudio.osversion.VersionTranslator() + version = int("".join(openstudio.openStudioVersion().split("."))) + + path = openstudio.path("./tests/files/osms/out/seb_ext4.osm") + model = translator.loadModel(path) + self.assertTrue(model) + model = model.get() + spaces = model.getSpaces() + + slanted = osut.facets(spaces, "Outdoors", "RoofCeiling", ["top", "north"]) + self.assertEqual(len(slanted), 1) + slanted = slanted[0] + self.assertEqual(slanted.nameString(), "Openarea slanted roof") + skylights = slanted.subSurfaces() + + tilted = osut.facets(spaces, "Outdoors", "Wall", "bottom") + self.assertEqual(len(tilted), 1) + tilted = tilted[0] + self.assertEqual(tilted.nameString(), "Openarea tilted wall") + windows = tilted.subSurfaces() + + # 2x control groups: + # - 3x windows as a single control group + # - 3x skylight as another single control group + skies = openstudio.model.SubSurfaceVector() + wins = openstudio.model.SubSurfaceVector() + for sub in skylights: skies.append(sub) + for sub in windows: wins.append(sub) + + if version < 321: + self.assertFalse(osut.genShade(skies)) + else: + self.assertTrue(osut.genShade(skies)) + self.assertTrue(osut.genShade(wins)) + ctls = model.getShadingControls() + self.assertEqual(len(ctls), 2) + + for ctl in ctls: + self.assertEqual(ctl.shadingType(), "InteriorShade") + type = "OnIfHighOutdoorAirTempAndHighSolarOnWindow" + self.assertEqual(ctl.shadingControlType(), type) + self.assertTrue(ctl.isControlTypeValueNeedingSetpoint1()) + self.assertTrue(ctl.isControlTypeValueNeedingSetpoint2()) + self.assertTrue(ctl.isControlTypeValueAllowingSchedule()) + self.assertFalse(ctl.isControlTypeValueRequiringSchedule()) + spt1 = ctl.setpoint() + spt2 = ctl.setpoint2() + self.assertTrue(spt1) + self.assertTrue(spt2) + spt1 = spt1.get() + spt2 = spt2.get() + self.assertAlmostEqual(spt1, 18, places=2) + self.assertAlmostEqual(spt2, 100, places=2) + self.assertEqual(ctl.multipleSurfaceControlType(), "Group") + + for sub in ctl.subSurfaces(): + surface = sub.surface() + self.assertTrue(surface) + surface = surface.get() + self.assertTrue(surface in [slanted, tilted]) + + model.save("./tests/files/osms/out/seb_ext5.osm", True) + + del model + self.assertEqual(o.status(), 0) if __name__ == "__main__": unittest.main() From fd3c2d4750dbfecf009fbcf9b71f14cb32eeaf2f Mon Sep 17 00:00:00 2001 From: brgix Date: Tue, 22 Jul 2025 08:12:43 -0400 Subject: [PATCH 36/49] Adds 'grossRoofArea' and 'getHorizontalRidges' (untested) --- src/osut/osut.py | 217 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 215 insertions(+), 2 deletions(-) diff --git a/src/osut/osut.py b/src/osut/osut.py index 142f99f..4b0e0c8 100644 --- a/src/osut/osut.py +++ b/src/osut/osut.py @@ -3941,6 +3941,7 @@ def isRoof(pts=None) -> bool: Returns: bool: If considered a roof surface. False: If invalid input (see logs). + """ ray = openstudio.Point3d(0,0,1) - openstudio.Point3d(0,0,0) dut = math.cos(60 * math.pi / 180) @@ -5259,7 +5260,7 @@ def facets(spaces=[], boundary="all", type="all", sides=[]) -> list: return faces -def genSlab(pltz=[], z=0): +def genSlab(pltz=[], z=0) -> openstudio.Point3dVector: """Generates an OpenStudio 3D point vector of a composite floor "slab", a 'union' of multiple rectangular, horizontal floor "plates". Each plate must either share an edge with (or encompass or overlap) any of the @@ -5275,7 +5276,7 @@ def genSlab(pltz=[], z=0): - "z" (float): Z-axis coordinate. Returns: - openstudio.point3dVector: Slab vertices (see logs if empty). + openstudio.Point3dVector: Slab vertices (see logs if empty). """ mth = "osut.genSlab" slb = openstudio.Point3dVector() @@ -6123,6 +6124,218 @@ def addSubs(s=None, subs=[], clear=False, bound=False, realign=False, bfr=0.005) return True +def grossRoofArea(spaces=[]) -> float: + """Returns the 'gross' roof surface area above selected conditioned, + occupied spaces. This includes all roof surfaces of indirectly-conditioned, + unoccupied spaces like plenums (if located above any of the selected + spaces). This also includes roof surfaces of unconditioned or unenclosed + spaces like attics, if vertically-overlapping any ceiling of occupied + spaces below; attic roof sections above uninsulated soffits are excluded, + for instance. It does not include surfaces labelled as 'RoofCeiling', which + do not comply with ASHRAE 90.1 or NECB tilt criteria - see 'isRoof'. + + Args: + spaces (list): + Set of openstudio.model.Space instances. + + Returns: + float: Gross roof surface area. + 0: If invalid inputs (see logs). + + """ + mth = "osut.grossRoofArea" + up = openstudio.Point3d(0,0,1) - openstudio.Point3d(0,0,0) + rm2 = 0 + rfs = {} + + if isinstance(spaces, openstudio.modelSpace): spaces = [spaces] + + try: + spaces = list(spaces) + except: + return oslg.invalid("spaces", mth, 1, CN.DBG, rm2) + + spaces = [s for s in spaces if isinstance(s, openstudio.model.Space)] + spaces = [s for s in spaces if s.partofTotalFloorArea()] + spaces = [s for s in spaces if not isUnconditioned(s)] + + # The method is very similar to OpenStudio-Standards' : + # find_exposed_conditioned_roof_surfaces(model) + # + # github.com/NREL/openstudio-standards/blob/ + # be81bd88dc55a44d8cce3ee6daf29c768032df6a/lib/openstudio-standards/ + # standards/Standards.Surface.rb#L99 + # + # ... yet differs with regards to attics with overhangs/soffits. + # + # @todo: recursive call for stacked spaces as atria (via AirBoundaries). + # + # The overlap calculations below fail for roof and ceiling surfaces + # holding previously-added leader lines. + # + # @todo: revise approach for attics ONCE skylight wells have been added. + + # Start with roof surfaces of occupied, conditioned spaces. + for space in spaces: + for roof in facets(space, "Outdoors", "RoofCeiling"): + id = roof.nameString() + if id in rfs: continue + if not isRoof(roof): continue + + rfs[id] = dict(m2=roof.grossArea(), m=space.multiplier()) + + # Roof surfaces of unoccupied, conditioned spaces above (e.g. plenums)? + for space in spaces: + for ceiling in facets(space, "Surface", "RoofCeiling"): + floor = ceiling.adjacentSurface() + if not floor: continue + + other = floor.get().space() + if not other: continue + + other = other.get() + if other.partofTotalFloorArea(): continue + if isUnconditioned(other): continue + + for roof in facets(other, "Outdoors", "RoofCeiling"): + id = roof.nameString() + if id in rfs: continue + if not isRoof(roof): continue + + rfs[id] = dict(m2=roof.grossArea(), m=other.multiplier()) + + # Roof surfaces of unoccupied, unconditioned spaces above (e.g. attics)? + for space in spaces: + # When taking overlaps into account, target spaces often do not share + # the same local transformation as the space(s) above. + t0 = transforms(space) + if t0["t"] is None: continue + + t0 = t0["t"] + + for ceiling in facets(space, "Surface", "RoofCeiling"): + cv0 = t0 * ceiling.vertices() + + floor = ceiling.adjacentSurface() + if not floor: continue + + other = floor.get().space() + if not other: continue + + other = other.get() + if other.partofTotalFloorArea(): continue + if not isUnconditioned(other): continue + + ti = transforms(other) + if ti["t"] is None: continue + + ti = ti["t"] + + for roof in facets(other, "Outdoors", "RoofCeiling"): + id = roof.nameString() + if not isRoof(roof): continue + + rvi = ti * roof.vertices() + cst = cast(cv0, rvi, up) + if not cst: continue + + olap = None + olap = overlap(cst, rvi, False) + if not olap: continue + + m2 = openstudio.getArea(olap) + if not m2: continue + + m2 = m2.get() + if m2 < CN.TOL2: continue + + if id not in rfs: + rfs[id] = dict(m2=0, m=other.multiplier()) + + rfs[id]["m2"] += m2 + + for rf in rfs.values(): rm2 += rf["m2"] * rf["m"] + + return rm2 + + +def getHorizontalRidges(roofs=[]) -> list: + """Identifies horizontal ridges along 2x sloped 'roof' surfaces (same + space) - see 'isRoof'. Harmonized with OpenStudio's "alignZPrime" - see + 'isSloped'. + + Args: + roofs (list): + A set of 'roof' openstudio.model.Surface instances. + + Returns: + list: A set of horizontal roof ridge dictionaries: + - "edge" (openstudio.Point3dVector): both edge endpoints + - "length" (float): individual horizontal roof ridge length + - "roofs" (list): 2x linked roof surfaces, on either side of the edge + + """ + mth = "osut.getHorizontalRidges" + ridges = [] + + try: + roofs = list(roofs) + except: + return ridges + + roofs = [s for s in roofs if isinstance(s, openstudio.model.Surface)] + roofs = [s for s in roofs if isSloped(s)] + roofs = [s for s in roofs if isRoof(s)] + + for roof in roofs: + if not roof.space(): continue + + space = roof.space().get() + maxZ = max([pt.z() for pt in roof.vertices()]) + + for edge in segments(roof): + if not shareXYZ(edge, "z", maxZ): continue + + # Skip if already tracked. + match = False + + for ridge in ridges: + if match: break + + edg = list(ridge["edge"]) + edg2 = edg.reverse() + match = areSame(edge, edg) or areSame(edge, edg2) + + if match: continue + + ridge = {} + ridge["edge" ] = edge + ridge["length"] = (edge[1] - edge[0]).length() + ridge["roofs" ] = [roof] + + # Links another roof (same space)? + match = False + + for ruf in roofs: + if match: break + if ruf == roof: continue + if not ruf.space(): continue + if ruf.space().get() != space: continue + + for edg in segments(ruf): + if match: break + + edg1 = list(edg) + edg2 = edg.reverse() + + if areSame(edge, edg1) or areSame(edge, edg2): + ridge["roofs"].append(ruf) + ridges.append(ridge) + match = True + + return ridges + + def isDaylit(space=None, sidelit=True, toplit=True, baselit=True) -> bool: """Validates whether space has outdoor-facing surfaces with fenestration. From e57e6b6f283982bc196c3bc3daf3997efd798f65 Mon Sep 17 00:00:00 2001 From: brgix Date: Wed, 23 Jul 2025 07:56:04 -0400 Subject: [PATCH 37/49] Adds 'toToplit' - yet untested --- src/osut/osut.py | 134 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) diff --git a/src/osut/osut.py b/src/osut/osut.py index 4b0e0c8..5f88aa8 100644 --- a/src/osut/osut.py +++ b/src/osut/osut.py @@ -6336,6 +6336,140 @@ def getHorizontalRidges(roofs=[]) -> list: return ridges +def toToplit(spaces=[], opts={}) -> list: + """Preselects ideal spaces to toplight, based on 'addSkylights' options and + key building model geometry attributes. This can be called from within + 'addSkylights' by setting opts["ration"] to True (False by default). + Alternatively, the method can be called prior to 'addSkylights'. The + optional filters stem from previous rounds of 'addSkylights' stress testing. + The goal is to allow users to prune away less ideal candidate spaces + (irregular, smaller) in favour of (larger) candidates (notably with more + suitable roof geometries). This is key when dealing with attic and plenums, + where 'addSkylights' seeks to add skylight wells (relying on roof cut-outs + and leader lines). Another check/outcome is whether to prioritize skylight + allocation in already sidelit spaces: opts["sidelit"] may be set to True. + + Args: + spaces (list): + Set of openstudio.model.Space instances. + opts (dict): + Requested skylight attributes (similar to 'addSkylights'). + - "size" (float): Template skylight width/depth (1.22m, min 0.4m) + + Returns: + list: Favoured openstudio.model.Space candidates (see logs if empty). + + """ + mth = "osut.toToplit" + gap4 = 0.4 # minimum skylight 16" width/depth (excluding frame width) + w = 1.22 # default 48" x 48" skylight base + + if not isinstance(opts, dict): + return oslg.mismatch("opts", opts, dict, mth, CN.DBG, []) + + # Validate skylight size, if provided. + if "size" in opts: + try: + w = float(opts["size"]) + except: + return oslg.mismatch("size", opts["size"], float, mth, CN.DBG, []) + + if round(w, 2) < gap4: return oslg.invalid("size", mth, 0, CN.ERR, []) + + w2 = w * w + + # Accept single 'OpenStudio::Model::Space' (vs an array of spaces). Filter. + if isinstance(spaces, openstudio.model.Space): spaces = [spaces] + + try: + spaces = list(spaces) + except: + return oslg.mismatch("spaces", spaces, list, mth, CN.DBG, []) + + # Whether individual spaces are UNCONDITIONED (e.g. attics, unheated areas) + # or flagged as NOT being part of the total floor area (e.g. unoccupied + # plenums), should of course reflect actual design intentions. It's up to + # modellers to correctly flag such cases - can't safely guess in lieu of + # design/modelling team. + # + # A friendly reminder: 'addSkylights' should be called separately for + # strictly SEMIHEATED spaces vs REGRIGERATED spaces vs all other CONDITIONED + # spaces, as per 90.1 and NECB requirements. + spaces = [s for s in spaces if isinstance(s, openstudio.model.Space)] + spaces = [s for s in spaces if s.partofTotalFloorArea()] + spaces = [s for s in spaces if not isUnconditioned(s)] + spaces = [s for s in spaces if not isVestibule(s)] + spaces = [s for s in spaces if roofs(s)] + spaces = [s for s in spaces if s.floorArea() < 4 * w2] + spaces = sorted(spaces, key=floorArea(), reverse=True) + if not spaces: return oslg.empty("spaces", mth, CN.WRN, []) + + # Unfenestrated spaces have no windows, glazed doors or skylights. By + # default, 'addSkylights' will prioritize unfenestrated spaces (over all + # existing sidelit ones) and maximize skylight sizes towards achieving the + # required skylight area target. This concentrates skylights for instance in + # typical (large) core spaces, vs (narrower) perimeter spaces. However, for + # less conventional spatial layouts, this default approach can produce less + # optimal skylight distributions. A balance is needed to prioritize large + # unfenestrated spaces when appropriate on one hand, while excluding smaller + # unfenestrated ones on the other. Here, exclusion is based on the average + # floor area of spaces to toplight. + fm2 = sum([s.floorArea() for s in spaces]) + afm2 = fm2 / len(spaces) + + unfen = [s for s in spaces if not isDaylit(s)] + unfen = sorted(unfen, key=floorArea(), reverse=True) + + # Target larger unfenestrated spaces, if sufficient in area. + if unfen: + if len(spaces) > len(unfen): + ufm2 = sum([s.floorArea() for s in unfen]) + u0fm2 = unfen[0].floorArea() + + if ufm2 > 0.33 * fm2 and u0fm2 > 3 * afm2: + unfen = [s for s in unfen if s.floorArea() < 0.25 * afm2] + spaces = [s for s in spaces if s not in unfen] + else: + opts["sidelit"] = True + else: + opts["sidelit"] = True + + espaces = {} + rooms = [] + toits = [] + + # Gather roof surfaces - possibly those of attics or plenums above. + for s in spaces: + id = s.nameString() + m2 = s.floorArea() + + for rf in roofs(s): + if id not in espaces: espaces[id] = dict(m2=m2, roofs=[]) + if rf not in espaces[id]["roofs"]: espaces[id]["roofs"].append(rf) + + # Priortize larger spaces. + espaces = dict(sorted(espaces.items(), key=lambda s: s[1]["m2"], reverse=True)) + + # Prioritize larger roof surfaces. + for s in espaces.values(): + s["roofs"] = sorted(s["roofs"], key=grossArea(), reverse=True) + + # Single out largest roof in largest space, key when dealing with shared + # attics or plenum roofs. + for s in espaces.values(): + rfs = [ruf for ruf in s["roofs"] if ruf not in toits] + if not rfs: continue + + rfs = sorted(rfs, key=grossArea(), reverse=True) + + toits.append(rfs[0]) + rooms.append(s) + + if not rooms: oslg.log(CN.INF, "No ideal toplit candidates (%s)" % mth) + + return rooms + + def isDaylit(space=None, sidelit=True, toplit=True, baselit=True) -> bool: """Validates whether space has outdoor-facing surfaces with fenestration. From 45e1cbdb1f3c36a83c3f662b9c841658a1705094 Mon Sep 17 00:00:00 2001 From: brgix Date: Thu, 24 Jul 2025 07:43:23 -0400 Subject: [PATCH 38/49] Adds 'genAnchors' - untested --- pyproject.toml | 3 - src/osut/osut.py | 197 +++++++++++++++++++++++++++++++++++++++++++++ tests/test_osut.py | 139 +++++++++++++++++++++++++++++++- 3 files changed, 335 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 8141f58..e371945 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,10 +7,7 @@ requires-python = ">=3.2" authors = [ {name = "Denis Bourgeois", email = "denis@rd2.ca"} ] maintainers = [ {name = "Denis Bourgeois", email = "denis@rd2.ca"} ] dependencies = [ - "re", - "math", "numpy", - "collections" "oslg", "openstudio>=3.6.1", "dataclasses", diff --git a/src/osut/osut.py b/src/osut/osut.py index 5f88aa8..52be00b 100644 --- a/src/osut/osut.py +++ b/src/osut/osut.py @@ -5156,6 +5156,203 @@ def alignedHeight(pts=None, force=False) -> float: return max(ys) - min(ys) +def genAnchors(s=None, set=[], tag="box") -> int: + """Identifies 'leader line anchors', i.e. specific 3D points of a (larger) + set (e.g. delineating a larger, parent polygon), each anchor linking the + BLC corner of one or more (smaller) subsets (free-floating within the + parent) - see follow-up 'genInserts'. Subsets may hold several 'tagged' + vertices (e.g. "box", "cbox"). By default, the solution seeks to anchor + subset "box" vertices. Users can select other tags, e.g. tag == "cbox". The + solution minimally validates individual subsets (e.g. no self-intersecting + polygons, coplanarity, no inter-subset conflicts, must fit within larger + set). Potential leader lines cannot intersect each other, similarly tagged + subsets or (parent) polygon edges. For highly-articulated cases (e.g. a + narrow parent polygon with multiple concavities, holding multiple subsets), + such leader line conflicts are likely unavoidable. It is recommended to + first sort subsets (e.g. based on surface areas), given the solution's + 'first-come-first-served' policy. Subsets without valid leader lines are + ultimately ignored (check for new set "void" keys, see error logs). The + larger set of points is expected to be in space coordinates - not building + or site coordinates, while subset points are expected to 'fit' in the larger + set. + + Args: + s (openstudio.Point3dVector): + A (larger) parent set of points. + set (list): + A collection of (smaller) sequenced points. + tag (str): + Selected subset vertices to target. + + Returns: + int: Number of successfully anchored subsets (see logs if missing). + + """ + mth = "osut.genAnchors" + n = 0 + id = s.nameString() if hasattr(s, "nameString") else "" + pts = poly(s) + ts = tuple(s) + + if not pts: + return oslg.invalid("%s polygon" % id, mth, 1, CN.DBG, n) + + try: + set = list(set) + except: + return oslg.mismatch("set", set, list, mth, CN.DBG, n) + + origin = openstudio.Point3d(0,0,0) + zenith = openstudio.Point3d(0,0,1) + ray = zenith - origin + + # Validate individual subsets. Purge surface-specific leader line anchors. + for i, st in enumerate(set): + str1 = id + "subset %d" % i+1 + str2 = str1 + " %s" % str(tag) + + if not isinstance(st, dict): + return oslg.mismatch(str1, st, dict, mth, CN.DBG, n) + + if tag not in st: + return oslg.hashkey(str1, st, tag, mth, CN.DBG, n) + + if not st[tag]: + return oslg.empty("%s vertices" % str2, mth, CN.DBG, n) + + if "out" in st: + if "t" not in st: + return oslg.hashkey(str1, st, "t", mth, CN.DBG, n) + if "ti" not in st: + return oslg.hashkey(str1, st, "ti", mth, CN. DBG, n) + if "t0" not in st: + return oslg.hashkey(str1, st, "t0", mth, CN.DBG, n) + + stt = poly(st[tag]) + + if not stt: + return oslg.invalid("%s polygon" % str2, mth, 0, CN.DBG, n) + + if not fits(stt, pts, True): + return oslg.invalid("%s gap % str2", mth, 0, CN.DBG, n) + + if "ld" in st: + ld = st["ld"] + + if not isinstance(ld, dict): + return oslg.invalid("%s leaders" % str1, mth, 0, CN.DBG, n) + + ld = dict(ld.items(), key=lambda k: k[0] == ts) + else: + st["ld"] = {} + + for i, st in enumerate(set): + # When a subset already holds a leader line anchor (from an initial call + # to 'genAnchors'), it inherits key "out" - a dictionary holding (among + # others) a 'realigned' set of points (by default a 'realigned' "box"). + # The latter is typically generated from an outdoor-facing roof. + # Subsequent calls to 'genAnchors' may send (as first argument) a + # corresponding ceiling tile below (both may be called from + # 'addSkylights'). Roof vs ceiling may neither share alignment + # transformation nor space/site transformation identities. All + # subsequent calls to 'genAnchors' shall recover the "out" points, + # apply a succession of de/alignments and transformations in sync, and + # overwrite tagged points. + # + # Although 'genAnchors' and 'genInserts' have both been developed to + # support anchor insertions in other cases (e.g. bay window in a wall), + # variables and terminology here continue pertain to roofs, ceilings, + # skylights and wells - less abstract, simpler to follow. + if "out" in st: + ti = st["ti" ] # unoccupied attic/plenum space site transformation + t0 = st["t0" ] # occupied space site transformation + t = st["t" ] # initial alignment transformation of roof surface + o = st["out"] + tpts = t0.inverse() * (ti * (t * (o["r"] * (o["t"] * o["set"])))) + tpts = cast(tpts, pts, ray) + + st[tag] = tpts + else: + if "t" not in st: + st["t"] = openstudio.Transformation.alignFace(pts) + + tpts = st["t"].inverse() * st[tag] + o = realignedFace(tpts, True) + tpts = st["t"] * (o["r"] * (o["t"] * o["set"])) + + st["out"] = o + st[tag ] = tpts + + # Identify candidate leader line anchors for each subset. + for i, st in enumerate(set): + candidates = [] + tpts = st[tag] + + for pt in pts: + ld = [pt, tpts[0]] + nb = 0 + + # Intersections between leader line and polygon edges. + for sg in segments(pts): + if nb != 0: break + if holds(sg, pt): continue + if doesLineIntersect(sg, ld): nb += 1 + + # Intersections between candidate leader line vs other subsets? + for other in set: + if nb != 0: break + if st == other: continue + + ost = other[tag] + + for sg in segments(ost): + if doesLineIntersect(ld, sg): nb += 1 + + # ... and previous leader lines (first come, first serve basis). + for other in set: + if nb != 0: break + if st == other: continue + if "ld" not in other: continue + if ts not in other["ld"]: continue + + ost = other[tag] + pld = other["ld"][ts] + if areSame(pld, pt): continue + if doesLineIntersect(ld, [pld, ost[0]]): nb += 1 + + # Finally, check for self-intersections. + for sg in segments(tpts): + if nb != 0: break + if holds(sg, tpts[0]): continue + if doesLineIntersect(sg, ld): nb += 1 + + if (sg[0] - sg[0]).cross(ld[0] - ld[0]).length() < TOL: + nb += 1 + + if nb == 0: candidates.append(pt) + + if candidates: + p0 = candidates[0] + l0 = (p0 - tpts[0]).length() + + for j, pt in enumerate(candidates): + if j == 0: continue + lj = (pt - tpts[0]).length() + + if lj < l0: + p0 = pt + l0 = lj + n += 1 + st["ld"][s] = p0 + else: + str = id + ("set #%d" % i+1) + msg = "%s: unable to anchor %s leader line (%s)" % (str, tag, mth) + oslg.log(WRN, msg) + st["void"] = True + + return n + + def facets(spaces=[], boundary="all", type="all", sides=[]) -> list: """Returns an array of OpenStudio space surfaces or subsurfaces that match criteria, e.g. exterior, north-east facing walls in hotel "lobby". Note diff --git a/tests/test_osut.py b/tests/test_osut.py index e11a5a3..d120634 100644 --- a/tests/test_osut.py +++ b/tests/test_osut.py @@ -4749,7 +4749,144 @@ def test32_outdoor_roofs(self): del model self.assertEqual(o.status(), 0) - # def test33_leader_line_anchors_inserts(self): + def test33_leader_line_anchors_inserts(self): + o = osut.oslg + self.assertEqual(o.status(), 0) + self.assertEqual(o.reset(DBG), DBG) + self.assertEqual(o.level(), DBG) + translator = openstudio.osversion.VersionTranslator() + + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + o0 = openstudio.Point3d( 0, 0, 0) + + # A larger polygon (s0, an upside-down "U"), defined ULC. + s0 = openstudio.Point3dVector() + s0.append(openstudio.Point3d( 2, 16, 20)) + s0.append(openstudio.Point3d( 2, 2, 20)) + s0.append(openstudio.Point3d( 8, 2, 20)) + s0.append(openstudio.Point3d( 8, 10, 20)) + s0.append(openstudio.Point3d(16, 10, 20)) + s0.append(openstudio.Point3d(16, 2, 20)) + s0.append(openstudio.Point3d(20, 2, 20)) + s0.append(openstudio.Point3d(20, 16, 20)) + + # Polygon s0 entirely encompasses 4x smaller polygons, s1 to s4. + s1 = openstudio.Point3dVector() + s1.append(openstudio.Point3d( 7, 3, 20)) + s1.append(openstudio.Point3d( 7, 7, 20)) + s1.append(openstudio.Point3d( 5, 7, 20)) + s1.append(openstudio.Point3d( 5, 3, 20)) + + s2 = openstudio.Point3dVector() + s2.append(openstudio.Point3d( 3, 11, 20)) + s2.append(openstudio.Point3d(10, 11, 20)) + s2.append(openstudio.Point3d(10, 15, 20)) + s2.append(openstudio.Point3d( 3, 15, 20)) + + s3 = openstudio.Point3dVector() + s3.append(openstudio.Point3d(12, 13, 20)) + s3.append(openstudio.Point3d(16, 11, 20)) + s3.append(openstudio.Point3d(17, 13, 20)) + s3.append(openstudio.Point3d(13, 15, 20)) + + s4 = openstudio.Point3dVector() + s4.append(openstudio.Point3d(19, 3, 20)) + s4.append(openstudio.Point3d(19, 6, 20)) + s4.append(openstudio.Point3d(17, 6, 20)) + s4.append(openstudio.Point3d(17, 3, 20)) + + area0 = openstudio.getArea(s0) + area1 = openstudio.getArea(s1) + area2 = openstudio.getArea(s2) + area3 = openstudio.getArea(s3) + area4 = openstudio.getArea(s4) + self.assertTrue(area0) + self.assertTrue(area1) + self.assertTrue(area2) + self.assertTrue(area3) + self.assertTrue(area4) + area0 = area0.get() + area1 = area1.get() + area2 = area2.get() + area3 = area3.get() + area4 = area4.get() + self.assertAlmostEqual(area0, 188, places=2) + self.assertAlmostEqual(area1, 8, places=2) + self.assertAlmostEqual(area2, 28, places=2) + self.assertAlmostEqual(area3, 10, places=2) + self.assertAlmostEqual(area4, 6, places=2) + + # Side tests: index of nearest/farthest box coordinate to grid origin. + self.assertEqual(osut.nearest(s1), 3) + self.assertEqual(osut.nearest(s2), 0) + self.assertEqual(osut.nearest(s3), 0) + self.assertEqual(osut.nearest(s4), 3) + self.assertEqual(osut.farthest(s1), 1) + self.assertEqual(osut.farthest(s2), 2) + self.assertEqual(osut.farthest(s3), 2) + self.assertEqual(osut.farthest(s4), 1) + + self.assertEqual(osut.nearest(s1, o0), 3) + self.assertEqual(osut.nearest(s2, o0), 0) + self.assertEqual(osut.nearest(s3, o0), 0) + self.assertEqual(osut.nearest(s4, o0), 3) + self.assertEqual(osut.farthest(s1, o0), 1) + self.assertEqual(osut.farthest(s2, o0), 2) + self.assertEqual(osut.farthest(s3, o0), 2) + self.assertEqual(osut.farthest(s4, o0), 1) + + # Box-specific grid instructions, i.e. 'subsets'. + set = [] + set.append(dict(box=s1, rows=1, cols=2, w0=1.4, d0=1.4, dX=0.2, dY=0.2)) + set.append(dict(box=s2, rows=2, cols=3, w0=1.4, d0=1.4, dX=0.2, dY=0.2)) + set.append(dict(box=s3, rows=1, cols=1, w0=2.6, d0=1.4, dX=0.2, dY=0.2)) + set.append(dict(box=s4, rows=1, cols=1, w0=2.6, d0=1.4, dX=0.2, dY=0.2)) + + area_s1 = set[0]["rows"] * set[0]["cols"] * set[0]["w0"] * set[0]["d0"] + area_s2 = set[1]["rows"] * set[1]["cols"] * set[1]["w0"] * set[1]["d0"] + area_s3 = set[2]["rows"] * set[2]["cols"] * set[2]["w0"] * set[2]["d0"] + area_s4 = set[3]["rows"] * set[3]["cols"] * set[3]["w0"] * set[3]["d0"] + area_s = area_s1 + area_s2 + area_s3 + area_s4 + self.assertAlmostEqual(area_s1, 3.92, places=2) + self.assertAlmostEqual(area_s2, 11.76, places=2) + self.assertAlmostEqual(area_s3, 3.64, places=2) + self.assertAlmostEqual(area_s4, 3.64, places=2) + self.assertAlmostEqual(area_s, 22.96, places=2) + + # Side test. + ld1 = openstudio.Point3d(18, 0, 0) + ld2 = openstudio.Point3d( 8, 3, 0) + sg1 = openstudio.Point3d(12, 14, 0) + sg2 = openstudio.Point3d(12, 6, 0) + self.assertFalse(osut.lineIntersection([sg1, sg2], [ld1, ld2])) + + # To support multiple polygon inserts within a larger polygon, subset + # boxes must be first 'aligned' (along a temporary XY plane) in a + # systematic way to ensure consistent treatment between sequential + # methods, e.g.: + t = openstudio.Transformation.alignFace(s0) + s00 = t.inverse() * s0 + s01 = t.inverse() * s4 + + for pt in s01: self.assertTrue(osut.isPointWithinPolygon(pt, s00, True)) + + # Reiterating that if one simply 'aligns' an already flat surface, what + # ends up being considered a BottomLeftCorner (BLC) vs ULC is contingent + # on how OpenStudio's 'alignFace' rotates the original surface. Although + # 'alignFace' operates in a systematic and reliable way, its output + # isn't always intuitive when dealing with flat surfaces. Here, instead + # of the original upside-down "U" shape of s0, an aligned s00 presents a + # conventional "U" shape (i.e. 180° rotation). + # + # for sv in s00: print(sv) + # [18, 0, 0] ... vs [ 2, 16, 20] + # [18, 14, 0] ... vs [ 2, 2, 20] + # [12, 14, 0] ... vs [ 8, 2, 20] + # [12, 6, 0] ... vs [ 8, 10, 20] + # [ 4, 6, 0] ... vs [16, 10, 20] + # [ 4, 14, 0] ... vs [16, 2, 20] + # [ 0, 14, 0] ... vs [20, 2, 20] + # [ 0, 0, 0] ... vs [20, 16, 20] # def test34_generated_skylight_wells(self): From 71084393774a0ddceea99d4ebdb21318ad746ecf Mon Sep 17 00:00:00 2001 From: brgix Date: Fri, 25 Jul 2025 16:59:01 -0400 Subject: [PATCH 39/49] Adds 'genextendedvertices' - untested --- src/osut/osut.py | 113 ++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 102 insertions(+), 11 deletions(-) diff --git a/src/osut/osut.py b/src/osut/osut.py index 52be00b..93de06c 100644 --- a/src/osut/osut.py +++ b/src/osut/osut.py @@ -5190,7 +5190,7 @@ def genAnchors(s=None, set=[], tag="box") -> int: """ mth = "osut.genAnchors" n = 0 - id = s.nameString() if hasattr(s, "nameString") else "" + id = "%s " % s.nameString() if hasattr(s, "nameString") else "" pts = poly(s) ts = tuple(s) @@ -5208,7 +5208,7 @@ def genAnchors(s=None, set=[], tag="box") -> int: # Validate individual subsets. Purge surface-specific leader line anchors. for i, st in enumerate(set): - str1 = id + "subset %d" % i+1 + str1 = id + "subset %d" % (i+1) str2 = str1 + " %s" % str(tag) if not isinstance(st, dict): @@ -5220,14 +5220,6 @@ def genAnchors(s=None, set=[], tag="box") -> int: if not st[tag]: return oslg.empty("%s vertices" % str2, mth, CN.DBG, n) - if "out" in st: - if "t" not in st: - return oslg.hashkey(str1, st, "t", mth, CN.DBG, n) - if "ti" not in st: - return oslg.hashkey(str1, st, "ti", mth, CN. DBG, n) - if "t0" not in st: - return oslg.hashkey(str1, st, "t0", mth, CN.DBG, n) - stt = poly(st[tag]) if not stt: @@ -5236,6 +5228,14 @@ def genAnchors(s=None, set=[], tag="box") -> int: if not fits(stt, pts, True): return oslg.invalid("%s gap % str2", mth, 0, CN.DBG, n) + if "out" in st: + if "t" not in st: + return oslg.hashkey(str1, st, "t", mth, CN.DBG, n) + if "ti" not in st: + return oslg.hashkey(str1, st, "ti", mth, CN. DBG, n) + if "t0" not in st: + return oslg.hashkey(str1, st, "t0", mth, CN.DBG, n) + if "ld" in st: ld = st["ld"] @@ -5345,7 +5345,7 @@ def genAnchors(s=None, set=[], tag="box") -> int: n += 1 st["ld"][s] = p0 else: - str = id + ("set #%d" % i+1) + str = id + ("set #%d" % (i+1)) msg = "%s: unable to anchor %s leader line (%s)" % (str, tag, mth) oslg.log(WRN, msg) st["void"] = True @@ -5353,6 +5353,97 @@ def genAnchors(s=None, set=[], tag="box") -> int: return n +def genExtendedVertices(s=None, set=[], tag="vtx") -> openstudio.Point3dVector: + """Extends (larger) polygon vertices to circumscribe one or more (smaller) + subsets of vertices, based on previously-generated 'leader line' anchors. + The solution minimally validates individual subsets (e.g. no + self-intersecting polygons, coplanarity, no inter-subset conflicts, must fit + within larger set). Valid leader line anchors (set key "ld") need to be + generated prior to calling the method - see 'genAnchors'. Subsets may hold + several 'tag'ged vertices (e.g. "box", "vtx"). By default, the solution + seeks to anchor subset "vtx" vertices. Users can select other tags, e.g. + tag == "box"). + + Args: + s (openstudio.Point3dVector): + A (larger) parent set of points. + set (list): + A collection of (smaller) sequenced points. + tag (str): + Selected subset vertices to target. + + Returns: + openstudio.Point3dVector: Extended vertices (see logs if empty). + + """ + mth = "osut.genExtendedVertices" + id = "%s " % s.nameString() if hasattr(s, "nameString") else "" + f = False + pts = poly(s) + ts = tuple(s) + cl = openstudio.Point3d + a = openstudio.Point3dVector() + v = [] + + if not pts: + return oslg.invalid("%s polygon" % id, mth, 1, CN.DBG, a) + + try: + set = list(set) + except: + return oslg.mismatch("set", set, list, mth, CN.DBG, a) + + # Validate individual sets. + for i, st in enumerate(set): + str1 = id + "subset %d" % (i+1) + str2 = str1 + " %s" % str(tag) + + if not isinstance(st, dict): + return oslg.mismatch(str1, st, dict, mth, CN.DBG, a) + + if "void" in st and st["void"]: continue + + if tag not in st: + return oslg.hashkey(str1, st, tag, mth, CN.DBG, a) + + if not st[tag]: + return oslg.empty("%s vertices" % str2, mth, CN.DBG, a) + + stt = poly(st[tag]) + + if not stt: + return oslg.invalid("%s polygon" % str2, mth, 0, CN.DBG, a) + + if "ld" not in st: + return oslg.hashkey(str1, st, "ld", mth, CN.DBG, a) + + ld = st["ld"] + + if not isinstance(ld, dict): + return oslg.invalid("%s leaders" % str2, mth, 0, CN.DBG, a) + + if ts not in ld: + return oslg.hashkey("%s leader?" % str2, ld, ts, mth, CN.DBG, a) + + if not isinstance(ld[ts], cl): + return oslg.mismatch("%s point" % str2, ld[ts], cl, mth, CN.DBG, a) + + # Re-sequence polygon vertices. + for pt in pts: + v.append(pt) + + # Loop through each valid set; concatenate circumscribing vertices. + for st in set: + if "void" in st and st["void"]: continue + if not areSame(st["ld"][ts], pt): continue + if tag not in st: continue + + v += list(st[tag]) + v.append(pt) + + return p3Dv(v) + + def facets(spaces=[], boundary="all", type="all", sides=[]) -> list: """Returns an array of OpenStudio space surfaces or subsurfaces that match criteria, e.g. exterior, north-east facing walls in hotel "lobby". Note From 11397bb30e4024be79af9fece8454b080affc52e Mon Sep 17 00:00:00 2001 From: brgix Date: Mon, 28 Jul 2025 06:51:55 -0400 Subject: [PATCH 40/49] Sets up 'addSkylights', fixes 'overlap' --- src/osut/osut.py | 72 ++++++++++++++++--------- tests/test_osut.py | 128 +++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 173 insertions(+), 27 deletions(-) diff --git a/src/osut/osut.py b/src/osut/osut.py index 93de06c..fbea02e 100644 --- a/src/osut/osut.py +++ b/src/osut/osut.py @@ -4140,7 +4140,6 @@ def overlap(p1=None, p2=None, flat=False) -> bool: """ mth = "osut.overlap" - t = None face = openstudio.Point3dVector() p01 = poly(p1) p02 = poly(p2) @@ -4151,21 +4150,47 @@ def overlap(p1=None, p2=None, flat=False) -> bool: if not isinstance(flat, bool): flat = False if shareXYZ(p01, "z"): - a1 = list(p01) - a2 = list(p02) - if isClockwise(p01): a1.reverse() + t = None + a1 = list(p01) + a2 = list(p02) + cw1 = isClockwise(p01) + + if cw1: + a1.reverse() + a1 = list(a1) else: - t = openstudio.Transformation.alignFace(p01) - cw1 = False - a1 = list(t.inverse() * p01) - a2 = list(t.inverse() * p02) + t = openstudio.Transformation.alignFace(p01) + a1 = list(t.inverse() * p01) + a2 = list(t.inverse() * p02) if flat: a2 = list(flatten(a2)) if not shareXYZ(a2, "z"): return invalid("points 2", mth, 2, CN.DBG, face) - if isClockwise(a2): a2.reverse() + cw2 = isClockwise(a2) + + if cw2: + a2.reverse() + a2 = list(a2) + + # Return either (transformed) polygon if one fits into the other. + p02 = list(a2) + + if t: + if not cw2: p02.reverse() + + p02 = p3Dv(t * p02) + else: + if cw1: + if cw2: p02.reverse() + else: + if not cw2: p02.reverse() + + p02 = p3Dv(p02) + + if fits(a1, a2): return p01 + if fits(a2, a1): return p02 area1 = openstudio.getArea(a1) area2 = openstudio.getArea(a2) @@ -4188,7 +4213,9 @@ def overlap(p1=None, p2=None, flat=False) -> bool: delta = area1 + area2 - area if area > CN.TOL: - if round(delta, 2) == 0: return face + if round(area, 2) == round(area1, 2): return face + if round(area, 2) == round(area1, 2): return face + if round(delta, 2) == 0: return face res = openstudio.intersect(a1, a2, CN.TOL) if not res: return face @@ -6436,7 +6463,7 @@ def grossRoofArea(spaces=[]) -> float: rm2 = 0 rfs = {} - if isinstance(spaces, openstudio.modelSpace): spaces = [spaces] + if isinstance(spaces, openstudio.model.Space): spaces = [spaces] try: spaces = list(spaces) @@ -6455,13 +6482,6 @@ def grossRoofArea(spaces=[]) -> float: # standards/Standards.Surface.rb#L99 # # ... yet differs with regards to attics with overhangs/soffits. - # - # @todo: recursive call for stacked spaces as atria (via AirBoundaries). - # - # The overlap calculations below fail for roof and ceiling surfaces - # holding previously-added leader lines. - # - # @todo: revise approach for attics ONCE skylight wells have been added. # Start with roof surfaces of occupied, conditioned spaces. for space in spaces: @@ -6493,6 +6513,7 @@ def grossRoofArea(spaces=[]) -> float: rfs[id] = dict(m2=roof.grossArea(), m=other.multiplier()) # Roof surfaces of unoccupied, unconditioned spaces above (e.g. attics)? + # @todo: recursive call for stacked spaces as atria (via AirBoundaries). for space in spaces: # When taking overlaps into account, target spaces often do not share # the same local transformation as the space(s) above. @@ -6527,7 +6548,11 @@ def grossRoofArea(spaces=[]) -> float: cst = cast(cv0, rvi, up) if not cst: continue - olap = None + # The overlap calculations below fail for roof and ceiling + # surfaces holding previously-added leader lines. + # + # @todo: revise approach for attics ONCE skylight wells have + # been added. olap = overlap(cst, rvi, False) if not olap: continue @@ -6536,13 +6561,12 @@ def grossRoofArea(spaces=[]) -> float: m2 = m2.get() if m2 < CN.TOL2: continue - - if id not in rfs: - rfs[id] = dict(m2=0, m=other.multiplier()) + if id not in rfs: rfs[id] = dict(m2=0, m=other.multiplier()) rfs[id]["m2"] += m2 - for rf in rfs.values(): rm2 += rf["m2"] * rf["m"] + for rf in rfs.values(): + rm2 += rf["m2"] * rf["m"] return rm2 @@ -6808,6 +6832,6 @@ def isDaylit(space=None, sidelit=True, toplit=True, baselit=True) -> bool: for sub in surface.subSurfaces(): # All fenestrated subsurface types are considered, as user can set # these explicitly (e.g. skylight in a wall) in OpenStudio. - if isFenestration(sub): return True + if isFenestrated(sub): return True return False diff --git a/tests/test_osut.py b/tests/test_osut.py index d120634..ef3c96a 100644 --- a/tests/test_osut.py +++ b/tests/test_osut.py @@ -2633,7 +2633,7 @@ def test23_fits_overlaps(self): self.assertEqual(o.reset(DBG), DBG) self.assertEqual(o.level(), DBG) - v = int("".join(openstudio.openStudioVersion().split("."))) + version = int("".join(openstudio.openStudioVersion().split("."))) p1 = openstudio.Point3dVector() p2 = openstudio.Point3dVector() @@ -2645,7 +2645,7 @@ def test23_fits_overlaps(self): t = openstudio.Transformation.alignFace(p1) - if v < 340: + if version < 340: p2.append(openstudio.Point3d(3.63, 0, 2.49)) p2.append(openstudio.Point3d(3.63, 0, 1.00)) p2.append(openstudio.Point3d(7.34, 0, 1.00)) @@ -4888,7 +4888,129 @@ def test33_leader_line_anchors_inserts(self): # [ 0, 14, 0] ... vs [20, 2, 20] # [ 0, 0, 0] ... vs [20, 16, 20] - # def test34_generated_skylight_wells(self): + def test34_generated_skylight_wells(self): + o = osut.oslg + self.assertEqual(o.status(), 0) + self.assertEqual(o.reset(DBG), DBG) + self.assertEqual(o.level(), DBG) + + version = int("".join(openstudio.openStudioVersion().split("."))) + translator = openstudio.osversion.VersionTranslator() + + path = openstudio.path("./tests/files/osms/in/smalloffice.osm") + model = translator.loadModel(path) + self.assertTrue(model) + model = model.get() + + srr = 0.05 + 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(): + id = space.nameString() + + if not space.partofTotalFloorArea(): + attic.append(space) + continue + + sidelit = osut.isDaylit(space, True, False) + toplit = osut.isDaylit(space, False) + self.assertFalse(toplit) + + if "Perimeter" in id: + self.assertTrue(sidelit) + elif "Core" in id: + self.assertFalse(sidelit) + core.append(space) + + self.assertEqual(len(core), 1) + self.assertEqual(len(attic), 1) + core = core[0] + attic = attic[0] + self.assertFalse(osut.arePlenums(attic)) + self.assertTrue(osut.isUnconditioned(attic)) + + # TOTAL attic roof area, including overhangs. + roofs = osut.facets(attic, "Outdoors", "RoofCeiling") + rufs = osut.roofs(model.getSpaces()) + total1 = sum([roof.grossArea() for roof in roofs]) + total2 = sum([roof.grossArea() for roof in rufs]) + self.assertAlmostEqual(total1, total2, places=2) + self.assertAlmostEqual(total2, 598.76, places=2) + + # attic = model.getSpaceByName("Attic") + # pZN4 = model.getSpaceByName("Perimeter_ZN_4") + # self.assertTrue(attic) + # self.assertTrue(pZN4) + # attic = attic.get() + # pZN4 = pZN4.get() + # pZN4_ceiling = model.getSurfaceByName("Perimeter_ZN_4_ceiling") + # aSouth_roof = model.getSurfaceByName("Attic_roof_south") + # self.assertTrue(pZN4_ceiling) + # self.assertTrue(aSouth_roof) + # pZN4_ceiling = pZN4_ceiling.get() + # aSouth_roof = aSouth_roof.get() + # + # up = openstudio.Point3d(0,0,1) - openstudio.Point3d(0,0,0) + # t0 = osut.transforms(attic) + # ti = osut.transforms(pZN4) + # + # roof0 = t0["t"] * aSouth_roof.vertices() + # ceili = ti["t"] * pZN4_ceiling.vertices() + # + # print(" --- ROOF0 --- --- --- --- --- ") + # for pt in roof0: print(pt) + # print(" --- CEILI --- --- --- --- --- ") + # for pt in ceili: print(pt) + # + # cst = osut.cast(ceili, roof0, up) + # print(" --- CST --- --- --- --- --- ") + # for pt in cst: print(pt) + # + # olap = osut.overlap(cst, roof0, False) + # print(olap.__class__.__name__) + # print(len(olap)) + # for pt in olap: print(pt) + # self.assertTrue(olap) + # print(" --- OLAP --- --- --- --- --- ") + # for pt in olap: print(pt) + # + # m2 = openstudio.getArea(olap) + # self.assertTrue(m2) + # m2 = m2.get() + # print(" --- m2 --- --- --- --- --- ") + # print(m2) + + # "GROSS ROOF AREA" (GRA), as per 90.1/NECB - excludes overhangs (60m2) + gra1 = osut.grossRoofArea(model.getSpaces()) + self.assertAlmostEqual(gra1, 538.86, places=2) + + self.assertEqual(o.status(), 0) + del model def test35_facet_retrieval(self): o = osut.oslg From ef390a8ff988b37680cd3d1747610c19ced5b7db Mon Sep 17 00:00:00 2001 From: brgix Date: Fri, 1 Aug 2025 10:35:55 -0400 Subject: [PATCH 41/49] First draft of 'addSkylights': inoperable/inactive for now ... --- src/osut/osut.py | 2250 ++++++++++++++++++++++++++++++++++++++++---- tests/test_osut.py | 152 ++- 2 files changed, 2146 insertions(+), 256 deletions(-) diff --git a/src/osut/osut.py b/src/osut/osut.py index fbea02e..8ac03ca 100644 --- a/src/osut/osut.py +++ b/src/osut/osut.py @@ -286,10 +286,10 @@ def genConstruction(model=None, specs=dict()): if "type" not in specs: specs["type"] = "wall" if "id" not in specs: specs["id" ] = "" - id = oslg.trim(specs["id"]) + ide = oslg.trim(specs["id"]) - if not id: - id = "OSut.CON." + specs["type"] + if not ide: + 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: @@ -593,7 +593,7 @@ def genConstruction(model=None, specs=dict()): layers.append(lyr) c = openstudio.model.Construction(layers) - c.setName(id) + c.setName(ide) # Adjust insulating layer thickness or conductivity to match requested Uo. if not a["glazing"]: @@ -671,34 +671,34 @@ def genShade(subs=None) -> bool: # Shading availability period. model = subs[0].model() - id = "onoff" - onoff = model.getScheduleTypeLimitsByName(id) + ide = "onoff" + onoff = model.getScheduleTypeLimitsByName(ide) if onoff: onoff = onoff.get() else: onoff = openstudio.model.ScheduleTypeLimits(model) - onoff.setName(id) + onoff.setName(ide) onoff.setLowerLimitValue(0) onoff.setUpperLimitValue(1) onoff.setNumericType("Discrete") onoff.setUnitType("Availability") # Shading schedule. - id = "OSut.SHADE.Ruleset" - sch = model.getScheduleRulesetByName(id) + ide = "OSut.SHADE.Ruleset" + sch = model.getScheduleRulesetByName(ide) if sch: sch = sch.get() else: sch = openstudio.model.ScheduleRuleset(model, 0) - sch.setName(id) + sch.setName(ide) sch.setScheduleTypeLimits(onoff) sch.defaultDaySchedule().setName("OSut.SHADE.Ruleset.Default") # Summer cooling rule. - id = "OSut.SHADE.ScheduleRule" - rule = model.getScheduleRuleByName(id) + ide = "OSut.SHADE.ScheduleRule" + rule = model.getScheduleRuleByName(ide) if rule: rule = rule.get() @@ -709,7 +709,7 @@ def genShade(subs=None) -> bool: finish = openstudio.Date(october, 31) rule = openstudio.model.ScheduleRule(sch) - rule.setName(id) + rule.setName(ide) rule.setStartDate(start) rule.setEndDate(finish) rule.setApplyAllDays(True) @@ -717,19 +717,19 @@ def genShade(subs=None) -> bool: rule.daySchedule().addValue(openstudio.Time(0,24,0,0), 1) # Shade object. - id = "OSut.SHADE" - shd = model.getShadeByName(id) + ide = "OSut.SHADE" + shd = model.getShadeByName(ide) if shd: shd = shd.get() else: shd = openstudio.model.Shade(model) - shd.setName(id) + shd.setName(ide) # Shading control (unique to each call). - id = "OSut.ShadingControl" + ide = "OSut.ShadingControl" ctl = openstudio.model.ShadingControl(shd) - ctl.setName(id) + ctl.setName(ide) ctl.setSchedule(sch) ctl.setShadingControlType("OnIfHighOutdoorAirTempAndHighSolarOnWindow") ctl.setSetpoint(18) # °C @@ -776,14 +776,14 @@ def genMass(sps=None, ratio=2.0) -> bool: # A single material. mdl = sps[0].model() - id = "OSut.MASS.Material" - mat = mdl.getOpaqueMaterialByName(id) + ide = "OSut.MASS.Material" + mat = mdl.getOpaqueMaterialByName(ide) if mat: mat = mat.get() else: mat = openstudio.model.StandardOpaqueMaterial(mdl) - mat.setName(id) + mat.setName(ide) mat.setRoughness("MediumRough") mat.setThickness(0.15) mat.setConductivity(1.12) @@ -794,26 +794,26 @@ def genMass(sps=None, ratio=2.0) -> bool: mat.setVisibleAbsorptance(0.17) # A single, 1x layered construction. - id = "OSut.MASS.Construction" - con = mdl.getConstructionByName(id) + ide = "OSut.MASS.Construction" + con = mdl.getConstructionByName(ide) if con: con = con.get() else: con = openstudio.model.Construction(mdl) - con.setName(id) + con.setName(ide) layers = openstudio.model.MaterialVector() layers.append(mat) con.setLayers(layers) - id = "OSut.InternalMassDefinition.%.2f" % ratio - df = mdl.getInternalMassDefinitionByName(id) + ide = "OSut.InternalMassDefinition.%.2f" % ratio + df = mdl.getInternalMassDefinitionByName(ide) if df: df = df.get else: df = openstudio.model.InternalMassDefinition(mdl) - df.setName(id) + df.setName(ide) df.setConstruction(con) df.setSurfaceAreaperSpaceFloorArea(ratio) @@ -1198,11 +1198,11 @@ def insulatingLayer(lc=None) -> dict: if not isinstance(lc, cl): return oslg.mismatch("lc", lc, cl, mth, CN.DBG, res) - for m in lc.layers(): - if m.to_MasslessOpaqueMaterial(): - m = m.to_MasslessOpaqueMaterial().get() + for l in lc.layers(): + if l.to_MasslessOpaqueMaterial(): + l = l.to_MasslessOpaqueMaterial().get() - if m.thermalResistance() < 0.001 or m.thermalResistance() < res["r"]: + if l.thermalResistance() < 0.001 or l.thermalResistance() < res["r"]: i += 1 continue else: @@ -1210,10 +1210,10 @@ def insulatingLayer(lc=None) -> dict: res["index"] = i res["type" ] = "massless" - if m.to_StandardOpaqueMaterial(): - m = m.to_StandardOpaqueMaterial().get() - k = m.thermalConductivity() - d = m.thickness() + 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 @@ -2151,7 +2151,7 @@ def arePlenums(set=None): # CASE A: it includes the substring "plenum" (case insensitive) in its # spaceType's name, or in the latter's standardsSpaceType string; # - # CASE B: "isPlenum" == TRUE in an OpenStudio model WITH HVAC airloops; OR + # CASE B: "isPlenum" is TRUE in an OpenStudio model WITH HVAC airloops; OR # # CASE C: its zone holds an 'inactive' thermostat (i.e. can't extract valid # setpoints) in an OpenStudio model with setpoint temperatures. @@ -2185,7 +2185,7 @@ def arePlenums(set=None): type = type.standardsSpaceType().get().lower() if "plenum" in type: continue - # CASE B: "isPlenum" == TRUE if airloops. + # CASE B: "isPlenum" is TRUE if airloops. if hasAirLoopsHVAC(space.model()): if space.isPlenum(): continue @@ -2253,18 +2253,18 @@ def setpoints(space=None): # 2. Check instead OSut's INDIRECTLYCONDITIONED (parent space) link. if cnd is None: - id = space.additionalProperties().getFeatureAsString(tg2) + ide = space.additionalProperties().getFeatureAsString(tg2) - if id: - id = id.get() - dad = space.model().getSpaceByName(id) + if ide: + ide = ide.get() + dad = space.model().getSpaceByName(ide) if dad: # Now focus on 'parent' space of INDIRECTLYCONDITIONED space. space = dad.get() cnd = tg2 else: - oslg.log(ERR, "Unknown space %s (%s)" % (id, mth)) + oslg.log(ERR, "Unknown space %s (%s)" % (ide, mth)) # 3. Fetch space setpoints (if model indeed holds valid setpoints). heated = hasHeatingTemperatureSetpoints(space.model()) @@ -2339,7 +2339,7 @@ def isRefrigerated(space=None): if not isinstance(space, cl): return oslg.mismatch("space", space, cl, mth, CN.DBG, False) - id = space.nameString() + ide = space.nameString() # 1. First check OSut's REFRIGERATED status. status = space.additionalProperties().getFeatureAsString(tg0) @@ -2347,7 +2347,7 @@ def isRefrigerated(space=None): if status: status = status.get() if isinstance(status, bool): return status - oslg.log(ERR, "Unknown %s REFRIGERATED %s (%s)" % (id, status, mth)) + oslg.log(ERR, "Unknown %s REFRIGERATED %s (%s)" % (ide, status, mth)) # 2. Else, compare design heating/cooling setpoints. stps = setpoints(space) @@ -2413,7 +2413,7 @@ def availabilitySchedule(model=None, avl=""): # Either fetch availability ScheduleTypeLimits object, or create one. for l in model.getScheduleTypeLimitss(): - id = l.nameString().lower() + ide = l.nameString().lower() if limits: break if not l.lowerLimitValue(): continue @@ -2423,7 +2423,7 @@ def availabilitySchedule(model=None, avl=""): if not int(l.upperLimitValue().get()) == 1: continue if not l.numericType().get().lower() == "discrete": continue if not l.unitType().lower() == "availability": continue - if id != "hvac operation scheduletypelimits": continue + if ide != "hvac operation scheduletypelimits": continue limits = l @@ -2617,7 +2617,7 @@ def trueNormal(s=None, r=0): return openstudio.Point3d(vx, vy, vz) - openstudio.Point3d(0, 0, 0) -def scalar(v=None, m=0) -> openstudio.Vector3d: +def scalar(v=None, mag=0) -> openstudio.Vector3d: """Returns scalar product of an OpenStudio Vector3d. Args: @@ -2638,11 +2638,11 @@ def scalar(v=None, m=0) -> openstudio.Vector3d: return oslg.mismatch("vector", v, cl, mth, CN.DBG, v0) try: - m = float(m) + mag = float(mag) except: - return oslg.mismatch("scalar", m, float, mth, CN.DBG, v0) + return oslg.mismatch("scalar", mag, float, mth, CN.DBG, v0) - v0 = openstudio.Vector3d(m * v.x(), m * v.y(), m * v.z()) + v0 = openstudio.Vector3d(mag * v.x(), mag * v.y(), mag * v.z()) return v0 @@ -5183,6 +5183,105 @@ def alignedHeight(pts=None, force=False) -> float: return max(ys) - min(ys) +def spacHeight(space=None) -> float: + """Fetch a space's full height. + + Args: + space (openstudio.model.Space): + An OpenStudio space. + + Returns: + (float): Full height of space (0.0 if invalid input). + + """ + if not isinstance(space, openstudio.model.Space): + return 0 + + hght = 0 + minZ = 10000 + maxZ = -10000 + + # The solution considers all surface types: "Floor", "Wall", "RoofCeiling". + # No presumption that floor are necessarily at ground level. + for surface in space.surfaces(): + for pts in surface.vertices(): + zs = [pt.z() for pt in pts] + + minZ = min(minZ, min(zs)) + maxZ = max(maxZ, max(zs)) + + hght = maxZ - minZ + if hght < 0: hght = 0 + + return maxZ - minZ + + +def spaceWidth(space=None) -> float: + """Fetches a space's 'width', i.e. at its narrowest. For instance, an 100m + (long) hospital corridor may only have a 'width' of 2.4m. This is a common + requirement for LPD calculations (e.g. 90.1, NECB). Not to confuse with + vertical wall widths in methods 'width' & 'alignedWidth'. + + Args: + space (openstudio.model.Space): + An OpenStudio space. + + Returns: + (float): Width of space (0.0 if invalid input). + + """ + if not isinstance(space, openstudio.model.Space): + return 0 + + floors = facets(space, "all", "Floor") + if not floors: return 0 + + # Automatically determining a space's "width" is not so straightforward: + # - a space may hold multiple floor surfaces at various Z-axis levels + # - a space may hold multiple floor surfaces, with unique "widths" + # - a floor surface may expand/contract (in "width") along its length. + # + # First, attempt to merge all floor surfaces together as 1x polygon: + # - select largest floor surface (in area) + # - determine its 3D plane + # - retain only other floor surfaces sharing same 3D plane + # - recover potential union between floor surfaces + # - fall back to largest floor surface if invalid union + floors = sorted(floors, key=lambda fl: fl.grossArea(), reverse=True) + floor = floors[0] + plane = floor.plane() + t = openstudio.Transformation.alignFace(floor.vertices()) + polyg = list(poly(floor, False, True, True, t, "ulc")) + + if not polyg: + oslg.clean() + return 0 + + polyg.reverse() + polyg = p3Dv(polyg) + + if len(floors) > 1: + floors = [flr for flr in floors if plane.equal(fl.plane(), 0.001)] + + if len(floors) > 1: + polygs = [poly(flr, False, True, True, t, "ulc") for flr in floors] + polygs = [plg for plg in polygs if plg] + + for plg in polygs: + plg = list(plg) + plg.reverse() + plg = p3Dv(plg) + + union = openstudio.joinAll(polygs, 0.01)[0] + polyg = poly(union, False, True, True) + + box = boundedBox(polyg) + oslg.clean() + + # A bounded box's 'height', at its narrowest, is its 'width'. + return height(box) + + def genAnchors(s=None, set=[], tag="box") -> int: """Identifies 'leader line anchors', i.e. specific 3D points of a (larger) set (e.g. delineating a larger, parent polygon), each anchor linking the @@ -5207,7 +5306,7 @@ def genAnchors(s=None, set=[], tag="box") -> int: s (openstudio.Point3dVector): A (larger) parent set of points. set (list): - A collection of (smaller) sequenced points. + A collection of (smaller) sequenced points, to 'anchor'. tag (str): Selected subset vertices to target. @@ -5217,12 +5316,12 @@ def genAnchors(s=None, set=[], tag="box") -> int: """ mth = "osut.genAnchors" n = 0 - id = "%s " % s.nameString() if hasattr(s, "nameString") else "" + ide = "%s " % s.nameString() if hasattr(s, "nameString") else "" pts = poly(s) - ts = tuple(s) + ids = id(s) if not pts: - return oslg.invalid("%s polygon" % id, mth, 1, CN.DBG, n) + return oslg.invalid("%s polygon" % ide, mth, 1, CN.DBG, n) try: set = list(set) @@ -5235,15 +5334,13 @@ def genAnchors(s=None, set=[], tag="box") -> int: # Validate individual subsets. Purge surface-specific leader line anchors. for i, st in enumerate(set): - str1 = id + "subset %d" % (i+1) + str1 = ide + "subset %d" % (i+1) str2 = str1 + " %s" % str(tag) if not isinstance(st, dict): return oslg.mismatch(str1, st, dict, mth, CN.DBG, n) - if tag not in st: return oslg.hashkey(str1, st, tag, mth, CN.DBG, n) - if not st[tag]: return oslg.empty("%s vertices" % str2, mth, CN.DBG, n) @@ -5251,7 +5348,6 @@ def genAnchors(s=None, set=[], tag="box") -> int: if not stt: return oslg.invalid("%s polygon" % str2, mth, 0, CN.DBG, n) - if not fits(stt, pts, True): return oslg.invalid("%s gap % str2", mth, 0, CN.DBG, n) @@ -5264,12 +5360,10 @@ def genAnchors(s=None, set=[], tag="box") -> int: return oslg.hashkey(str1, st, "t0", mth, CN.DBG, n) if "ld" in st: - ld = st["ld"] - - if not isinstance(ld, dict): + if not isinstance(st["ld"], dict): return oslg.invalid("%s leaders" % str1, mth, 0, CN.DBG, n) - ld = dict(ld.items(), key=lambda k: k[0] == ts) + st["ld"] = dict(st["ld"].items(), key=lambda k: k[0] != ids) else: st["ld"] = {} @@ -5300,8 +5394,7 @@ def genAnchors(s=None, set=[], tag="box") -> int: st[tag] = tpts else: - if "t" not in st: - st["t"] = openstudio.Transformation.alignFace(pts) + if "t" not in st: st["t"] = openstudio.Transformation.alignFace(pts) tpts = st["t"].inverse() * st[tag] o = realignedFace(tpts, True) @@ -5340,7 +5433,7 @@ def genAnchors(s=None, set=[], tag="box") -> int: if nb != 0: break if st == other: continue if "ld" not in other: continue - if ts not in other["ld"]: continue + if ids not in other["ld"]: continue ost = other[tag] pld = other["ld"][ts] @@ -5353,8 +5446,7 @@ def genAnchors(s=None, set=[], tag="box") -> int: if holds(sg, tpts[0]): continue if doesLineIntersect(sg, ld): nb += 1 - if (sg[0] - sg[0]).cross(ld[0] - ld[0]).length() < TOL: - nb += 1 + if (sg[0] - sg[0]).cross(ld[0] - ld[0]).length() < TOL: nb += 1 if nb == 0: candidates.append(pt) @@ -5369,12 +5461,13 @@ def genAnchors(s=None, set=[], tag="box") -> int: if lj < l0: p0 = pt l0 = lj + + st["ld"][ids] = p0 n += 1 - st["ld"][s] = p0 else: - str = id + ("set #%d" % (i+1)) - msg = "%s: unable to anchor %s leader line (%s)" % (str, tag, mth) - oslg.log(WRN, msg) + str = ide + ("set #%d" % (i+1)) + m = "%s: unable to anchor %s leader line (%s)" % (str, tag, mth) + oslg.log(WRN, m) st["void"] = True return n @@ -5404,16 +5497,15 @@ def genExtendedVertices(s=None, set=[], tag="vtx") -> openstudio.Point3dVector: """ mth = "osut.genExtendedVertices" - id = "%s " % s.nameString() if hasattr(s, "nameString") else "" + ide = "%s " % s.nameString() if hasattr(s, "nameString") else "" f = False pts = poly(s) - ts = tuple(s) + ids = id(s) cl = openstudio.Point3d a = openstudio.Point3dVector() v = [] - if not pts: - return oslg.invalid("%s polygon" % id, mth, 1, CN.DBG, a) + if not pts: return oslg.invalid("%s polygon" % ide, mth, 1, CN.DBG, a) try: set = list(set) @@ -5422,7 +5514,7 @@ def genExtendedVertices(s=None, set=[], tag="vtx") -> openstudio.Point3dVector: # Validate individual sets. for i, st in enumerate(set): - str1 = id + "subset %d" % (i+1) + str1 = ide + "subset %d" % (i+1) str2 = str1 + " %s" % str(tag) if not isinstance(st, dict): @@ -5446,14 +5538,14 @@ def genExtendedVertices(s=None, set=[], tag="vtx") -> openstudio.Point3dVector: ld = st["ld"] - if not isinstance(ld, dict): + if not isinstance(st["ld"], dict): return oslg.invalid("%s leaders" % str2, mth, 0, CN.DBG, a) - if ts not in ld: - return oslg.hashkey("%s leader?" % str2, ld, ts, mth, CN.DBG, a) + if ids not in st["ld"]: + return oslg.hashkey("%s leader?" % str2, st["ld"], ide, mth, CN.DBG, a) - if not isinstance(ld[ts], cl): - return oslg.mismatch("%s point" % str2, ld[ts], cl, mth, CN.DBG, a) + if not isinstance(ld[ids], cl): + return oslg.mismatch("%s point" % str2, st["ld"][ids], cl, mth, CN.DBG, a) # Re-sequence polygon vertices. for pt in pts: @@ -5462,7 +5554,7 @@ def genExtendedVertices(s=None, set=[], tag="vtx") -> openstudio.Point3dVector: # Loop through each valid set; concatenate circumscribing vertices. for st in set: if "void" in st and st["void"]: continue - if not areSame(st["ld"][ts], pt): continue + if not areSame(st["ld"][ids], pt): continue if tag not in st: continue v += list(st[tag]) @@ -5471,6 +5563,273 @@ def genExtendedVertices(s=None, set=[], tag="vtx") -> openstudio.Point3dVector: return p3Dv(v) +def genInserts(s=None, set=[]) -> openstudio.Point3dVector: + """Generates (1D or 2D) arrays of (smaller) rectangular collection of + points (e.g. arrays of polygon inserts) from subset parameters, within a + (larger) set (e.g. parent polygon). If successful, each subset inherits + additional key:value pairs: namely "vtx" (collection of circumscribing + vertices), and "vts" (collection of individual insert vertices). Valid + leader line anchors (set key "ld") need to be generated prior to calling + the solution - see 'genAnchors'. + + Args: + s (openstudio.Point3dVector): + A (larger) parent set of points. + set (list): + A collection of (smaller) sequenced points (dictionnaries). Each + collection shall/may hold the following key:value pairs. + - "box" (openstudio.Point3dVector): bounding box of each subset + - "ld" (dict): a collection of leader line anchors + - "rows" (int): number of rows of inserts + - "cols" (int): number of columns of inserts + - "w0" (float): width of individual inserts (wrt cols) min 0.4 + - "d0" (float): depth of individual inserts (wrt rows) min 0.4 + - "dX" (float): optional left/right X-axis buffer + - "dY" (float): optional top/bottom Y-axis buffer + + Returns: + openstudio.Point3dVector: New polygon vertices (see logs if empty). + + """ + mth = "osut.genInserts" + ide = "%s:" % s.nameString() if hasattr(s, "nameString") else "" + pts = poly(s) + cl = openstudio.Point3d + a = openstudio.Point3dVector() + if not pts: return a + + try: + set = list(set) + except: + return oslg.mismatch("set", set, list, mth, CN.DBG, a) + + gap = 0.1 + gap4 = 0.4 # minimum insert width/depth + + # Validate/reset individual set collections. + for i, st in enumerate(set): + str1 = ide + "subset #%d" % (i+1) + if "void" in st and st["void"]: continue + + if not isinstance(st, dict): + return oslg.mismatch(str1, st, dict, mth, CN.DBG, a) + if "box" not in st: + return oslg.hashkey(str1, st, "box", mth, CN.DBG, a) + if "ld" not in st: + return oslg.hashkey(str1, st, "ld", mth, CN.DBG, a) + if "out" not in st: + return oslg.hashkey(str1, st, "out", mth, CN.DBG, a) + + str2 = str1 + " anchor" + ld = st["ld"] + + if not isinstance(ld, dict): + return oslg.mismatch(str2, "ld", dict, mth, CN.DBG, a) + if id(s) not in ld: + return oslg.hashkey(str2, ld, s, mth, CN.DBG, a) + if not isinstance(ld[id(s)], cl): + return oslg.mismatch(str2, ld[id(s)], cl, mth, CN.DBG, a) + + # Ensure each set bounding box is safely within larger polygon + # boundaries. + # @todo: In line with related addSkylights' @todo, expand solution to + # safely handle 'side' cutouts (i.e. no need for leader lines). + # In so doing, boxes could eventually align along surface edges. + str3 = str1 + " box" + bx = poly(st["box"]) + + if not bx: + return invalid(str3, mth, 0, CN.DBG, a) + if not isRectangular(bx): + return oslg.invalid("%s rectangle" % str3, mth, 0, CN.DBG, a) + if not fits(bx, pts, True): + return invalid("%s box" % str3, mth, 0, CN.DBG, a) + + if "rows" in st: + try: + st["rows"] = int(st["rows"]) + except: + return oslg.invalid("%s rows" % ide, mth, 0, CN.DBG, a) + + if st["rows"] < 1: + return oslg.zero("%s rows" % ide, mth, CN.DBG, a) + else: + st["rows"] = 1 + + if "cols" in st: + try: + st["cols"] = int(st["cols"]) + except: + return oslg.invalid("%s cols" % ide, mth, 0, CN.DBG, a) + + if st["cols"] < 1: + return oslg.zero( "%s cols" % ide, mth, CN.DBG, a) + else: + st["cols"] = 1 + + if "w0" in st: + try: + st["w0"] = float(st["w0"]) + except: + return oslg.invalid("%s width" % ide, mth, 0, CN.DBG, a) + + if round(st["w0"], 2) < gap4: + return oslg.zero("%s width" % ide, mth, CN.DBG, a) + else: + st["w0"] = 1.4 + + if "d0" in st: + try: + st["d0"] = float(st["d0"]) + except: + return oslg.invalid("%s depth" % ide, mth, 0, CN.DBG, a) + + if round(st["d0"], 2) < gap4: + return oslg.zero("%s depth" % ide, mth, CN.DBG, a) + else: + st["d0"] = 1.4 + + if "dX" in st: + try: + st["dX"] = float(st["dX"]) + except: + return oslg.invalid("%s dX" % ide, mth, 0, CN.DBG, a) + else: + st["dX"] = None + + if "dY" in st: + try: + st["dY"] = float(st["dY"]) + except: + return oslg.invalid("%s dY" % ide, mth, 0, CN.DBG, a) + else: + st["dY"] = None + + # Flag conflicts between set bounding boxes. @todo: ease up for ridges. + for i, st in enumerate(set): + bx = st["box"] + if "void" in st and st["void"]: continue + + for j, other in enumerate(set): + if i == j: continue + bx2 = other["box"] + + if overlapping(bx, bx2): + str4 = ide + "set boxes #%d:#%d" % (i+1, j+1) + return oslg.invalid("%s (overlapping)" % str4, mth, 0, CN.DBG, a) + + + t = openstudio.Transformation.alignFace(pts) + rpts = t.inverse() * pts + + # Loop through each 'valid' subset (i.e. linking a valid leader line + # anchor), generate subset vertex array based on user-provided specs. + for i, st in enumerate(set): + str5 = ide + "subset #%d" % (i+1) + if "void" in st and st["void"]: continue + + o = st["out"] + vts = {} # collection of individual (named) polygon insert vertices + vtx = [] # sequence of circumscribing polygon vertices + bx = o["set"] + w = width(bx) # overall sandbox width + d = height(bx) # overall sandbox depth + dX = st["dX" ] # left/right buffer (array vs bx) + dY = st["dY" ] # top/bottom buffer (array vs bx) + cols = st["cols"] # number of array columns + rows = st["rows"] # number of array rows + x = st["w0" ] # width of individual insert + y = st["d0" ] # depth of individual insert + gX = 0 # gap between insert columns + gY = 0 # gap between insert rows + + # Gap between insert columns. + if cols > 1: + if not dX: dX = ((w - cols * x) / cols) / 2 + gX = (w - 2 * dX - cols * x) / (cols - 1) + if round(gX, 2) < gap: gX = gap + dX = (w - cols * x - (cols - 1) * gX) / 2 + else: + dX = (w - x) / 2 + + if round(dX, 2) < 0: + oslg.log(CN.ERR, "Skipping %s: Negative dX (%s)" % (str5, mth)) + continue + + # Gap between insert rows. + if rows > 1: + if not dY: dY = ((d - rows * y) / rows) / 2 + gY = (d - 2 * dY - rows * y) / (rows - 1) + if round(gY, 2) < gap: gY = gap + dY = (d - rows * y - (rows - 1) * gY) / 2 + else: + dY = (d - y) / 2 + + if round(dY, 2) < 0: + oslg.log(CN.ERR, "Skipping %s: Negative dY (%s)" % (str5, mth)) + continue + + st["dX"] = dX + st["gX"] = gX + st["dY"] = dY + st["gY"] = gY + + x0 = min([pt.x() for pt in bx]) + dX # X-axis starting point + y0 = min([pt.y() for pt in bx]) + dY # X-axis starting point + xC = x0 # current X-axis position + yC = y0 # current Y-axis position + + # BLC of array. + vtx.append(openstudio.Point3d(xC, yC, 0)) + + # Move up incrementally along left side of sandbox. + for iY in range(rows): + if iY != 0: + yC += gY + vtx.append(openstudio.Point3d(xC, yC, 0)) + + yC += y + vtx.append(openstudio.Point3d(xC, yC, 0)) + + # Loop through each row: left-to-right, then right-to-left. + for iY in range(rows): + for iX in range(cols - 1): + xC += x + vtx.append(openstudio.Point3d(xC, yC, 0)) + + xC += gX + vtx.append(openstudio.Point3d(xC, yC, 0)) + + # Generate individual polygon inserts, left-to-right. + for iX in range(cols): + nom = "%d:%d:%d" % (i, iX, iY) + vec = [] + vec.append(openstudio.Point3d(xC , yC , 0)) + vec.append(openstudio.Point3d(xC , yC - y, 0)) + vec.append(openstudio.Point3d(xC + x, yC - y, 0)) + vec.append(openstudio.Point3d(xC + x, yC , 0)) + + # Store. + vts[nom] = p3Dv(t * ulc(o["r"] * (o["t"] * vec))) + + # Add reverse vertices, circumscribing each insert. + vec.reverse() + if iX == cols - 1: vec.pop + + vtx += vec + if iX != cols - 1: xC -= gX + x + + if iY != rows - 1: + yC -= gY + y + vtx.append(openstudio.Point3d(xC, yC, 0)) + + st["vts"] = vts + st["vtx"] = p3Dv(t * (o["r"] * (o["t"] * vtx))) + + # Extended vertex sequence of the larger polygon. + genExtendedVertices(s, set) + + def facets(spaces=[], boundary="all", type="all", sides=[]) -> list: """Returns an array of OpenStudio space surfaces or subsurfaces that match criteria, e.g. exterior, north-east facing walls in hotel "lobby". Note @@ -5607,15 +5966,15 @@ def genSlab(pltz=[], z=0) -> openstudio.Point3dVector: return oslg.mismatch("Z", z, float, mth, CN.DBG, slb) for i, plt in enumerate(pltz): - id = "plate # %d (index %d)" % (i+1, i) + ide = "plate # %d (index %d)" % (i+1, i) if not isinstance(plt, dict): - return oslg.mismatch(id, plt, dict, mth, CN.DBG, slb) + return oslg.mismatch(ide, plt, dict, mth, CN.DBG, slb) - if "x" not in plt: return oslg.hashkey(id, plt, "x", mth, CN.DBG, slb) - if "y" not in plt: return oslg.hashkey(id, plt, "y", mth, CN.DBG, slb) - if "dx" not in plt: return oslg.hashkey(id, plt, "dx", mth, CN.DBG, slb) - if "dy" not in plt: return oslg.hashkey(id, plt, "dy", mth, CN.DBG, slb) + if "x" not in plt: return oslg.hashkey(ide, plt, "x", mth, CN.DBG, slb) + if "y" not in plt: return oslg.hashkey(ide, plt, "y", mth, CN.DBG, slb) + if "dx" not in plt: return oslg.hashkey(ide, plt, "dx", mth, CN.DBG, slb) + if "dy" not in plt: return oslg.hashkey(ide, plt, "dy", mth, CN.DBG, slb) x = plt["x" ] y = plt["y" ] @@ -5625,29 +5984,29 @@ def genSlab(pltz=[], z=0) -> openstudio.Point3dVector: try: x = float(x) except: - oslg.mismatch("%s X" % id, x, float, mth, CN.DBG, slb) + oslg.mismatch("%s X" % ide, x, float, mth, CN.DBG, slb) try: y = float(y) except: - oslg.mismatch("%s Y" % id, y, float, mth, CN.DBG, slb) + oslg.mismatch("%s Y" % ide, y, float, mth, CN.DBG, slb) try: dx = float(dx) except: - oslg.mismatch("%s dX" % id, dx, float, mth, CN.DBG, slb) + oslg.mismatch("%s dX" % ide, dx, float, mth, CN.DBG, slb) try: dy = float(dy) except: - oslg.mismatch("%s dY" % id, dy, float, mth, CN.DBG, slb) + oslg.mismatch("%s dY" % ide, dy, float, mth, CN.DBG, slb) - if abs(dx) < CN.TOL: return oslg.zero("%s dX" % id, mth, CN.ERR, slb) - if abs(dy) < CN.TOL: return oslg.zero("%s dY" % id, mth, CN.ERR, slb) + if abs(dx) < CN.TOL: return oslg.zero("%s dX" % ide, mth, CN.ERR, slb) + if abs(dy) < CN.TOL: return oslg.zero("%s dY" % ide, mth, CN.ERR, slb) # Join plates. for i, plt in enumerate(pltz): - id = "plate # %d (index %d)" % (i+1, i) + ide = "plate # %d (index %d)" % (i+1, i) x = plt["x" ] y = plt["y" ] @@ -5674,7 +6033,7 @@ def genSlab(pltz=[], z=0) -> openstudio.Point3dVector: if slab: slb = slab.get() else: - return oslg.invalid(id, mth, 0, CN.ERR, bkp) + return oslg.invalid(ide, mth, 0, CN.ERR, bkp) else: slb = vtx @@ -5705,9 +6064,10 @@ def roofs(spaces = []) -> list: list of openstudio.model.Surface instances: roofs (may be empty). """ - mth = "osut.getRoofs" - up = openstudio.Point3d(0,0,1) - openstudio.Point3d(0,0,0) - roofs = [] + mth = "osut.getRoofs" + up = openstudio.Point3d(0,0,1) - openstudio.Point3d(0,0,0) + rufs = [] + if isinstance(spaces, openstudio.model.Space): spaces = [spaces] try: @@ -5718,8 +6078,8 @@ def roofs(spaces = []) -> list: spaces = [s for s in spaces if isinstance(s, openstudio.model.Space)] # Space-specific outdoor-facing roof surfaces. - roofs = facets(spaces, "Outdoors", "RoofCeiling") - roofs = [roof for roof in roofs if isRoof(roof)] + rufs = facets(spaces, "Outdoors", "RoofCeiling") + rufs = [roof for roof in rufs if isRoof(roof)] for space in spaces: # When unoccupied spaces are involved (e.g. plenums, attics), the @@ -5755,12 +6115,12 @@ def roofs(spaces = []) -> list: cst = cast(cv0, rvi, up) if not overlapping(cst, rvi, False): continue - if ruf not in roofs: roofs.append(ruf) + if ruf not in rufs: rufs.append(ruf) - return roofs + return rufs -def addSubs(s=None, subs=[], clear=False, bound=False, realign=False, bfr=0.005): +def addSubs(s=None, subs=[], clear=False, bound=False, realign=False, bfr=0.005) -> bool: """Adds sub surface(s) (e.g. windows, doors, skylights) to a surface. Args: @@ -5947,12 +6307,12 @@ def addSubs(s=None, subs=[], clear=False, bound=False, realign=False, bfr=0.005) if sub["count" ] < 1: sub["count" ] = 1 if sub["multiplier"] < 1: sub["multiplier"] = 1 - id = sub["id"] + ide = sub["id"] # If sub surface type is invalid, log/reset. Additional corrections may # be enabled once a sub surface is actually instantiated. if sub["type"] not in types: - m = "Reset invalid '%s' type to '%s' (%s)" % (id, type, mth) + m = "Reset invalid '%s' type to '%s' (%s)" % (ide, type, mth) oslg.log(CN.WRN, m) sub["type"] = type @@ -5971,10 +6331,10 @@ def addSubs(s=None, subs=[], clear=False, bound=False, realign=False, bfr=0.005) sub["frame"] = None if sub["frame"] is None: - m = "Skip '%s' FrameDivider (%s)" % (id, mth) + m = "Skip '%s' FrameDivider (%s)" % (ide, mth) oslg.log(CN.WRN, m) else: - m = "Skip '%s' invalid FrameDivider object (%s)" % (id, mth) + m = "Skip '%s' invalid FrameDivider object (%s)" % (ide, mth) oslg.log(CN.WRN, m) sub["frame"] = None @@ -5984,7 +6344,7 @@ def addSubs(s=None, subs=[], clear=False, bound=False, realign=False, bfr=0.005) # activated once a sub surface is actually instantiated. if sub["assembly"]: if not isinstance(sub["assembly"], cl3): - m = "Skip invalid '%s' construction (%s)" % (id, mth) + m = "Skip invalid '%s' construction (%s)" % (ide, mth) oslg.log(WRN, m) sub["assembly"] = None @@ -6024,7 +6384,7 @@ def addSubs(s=None, subs=[], clear=False, bound=False, realign=False, bfr=0.005) # - Frame & Divider "width" # - minimum "clear glazing" limits # - buffers, etc. - id = sub["id"] + ide = sub["id"] frame = sub["frame"].frameWidth() if sub["frame"] else 0 frames = 2 * frame buffer = frame + bfr @@ -6056,10 +6416,10 @@ def addSubs(s=None, subs=[], clear=False, bound=False, realign=False, bfr=0.005) if (sub["height"] < glass - CN.TOL2 or sub["height"] > max_height + CN.TOL2): - m = "Reset '%s' height %.3fm (%s)" % (id, sub["height"], mth) + m = "Reset '%s' height %.3fm (%s)" % (ide, sub["height"], mth) oslg.log(CN.WRN, m) sub["height"] = numpy.clip(sub["height"], glass, max_height) - m = "Height '%s' reset to %.3fm (%s)" % (id, sub["height"], mth) + m = "Height '%s' reset to %.3fm (%s)" % (ide, sub["height"], mth) oslg.log(CN.WRN, m) # Log/reset "head" height if beyond min/max. @@ -6067,10 +6427,10 @@ def addSubs(s=None, subs=[], clear=False, bound=False, realign=False, bfr=0.005) if (sub["head"] < min_head - CN.TOL2 or sub["head"] > max_head + CN.TOL2): - m = "Reset '%s' head %.3fm (%s)" % (id, sub["head"], mth) + m = "Reset '%s' head %.3fm (%s)" % (ide, sub["head"], mth) oslg.log(CN.WRN, m) sub["head"] = numpy.clip(sub["head"], min_head, max_head) - m = "Head '%s' reset to %.3fm (%s)" % (id, sub["head"], mth) + m = "Head '%s' reset to %.3fm (%s)" % (ide, sub["head"], mth) oslg.log(CN.WRN, m) # Log/reset "sill" height if beyond min/max. @@ -6078,10 +6438,10 @@ def addSubs(s=None, subs=[], clear=False, bound=False, realign=False, bfr=0.005) if (sub["sill"] < min_sill - CN.TOL2 or sub["sill"] > max_sill + CN.TOL2): - m = "Reset '%s' sill %.3fm (%s)" % (id, sub["sill"], mth) + m = "Reset '%s' sill %.3fm (%s)" % (ide, sub["sill"], mth) oslg.log(CN.WRN, m) sub["sill"] = numpy.clip(sub["sill"], min_sill, max_sill) - m = "Sill '%s reset to %.3fm (%s)" % (id, sub["sill"], mth) + m = "Sill '%s reset to %.3fm (%s)" % (ide, sub["sill"], mth) oslg.log(CN.WRN, m) # At this point, "head", "sill" and/or "height" have been tentatively @@ -6098,14 +6458,14 @@ def addSubs(s=None, subs=[], clear=False, bound=False, realign=False, bfr=0.005) if "height" in sub: sub["height"] = 0 if "width" in sub: sub["width" ] = 0 - m = "Skip: invalid '%s' head/sill combo (%s)" % (id, mth) + m = "Skip: invalid '%s' head/sill combo (%s)" % (ide, mth) oslg.log(CN.ERR, m) continue else: - m = "Reset '%s' sill %.3fm (%s)" % (id, sub["sill"], mth) + m = "Reset '%s' sill %.3fm (%s)" % (ide, sub["sill"], mth) oslg.log(CN.WRN, m) sub["sill"] = sill - m = "Sill '%s' reset to %.3fm (%s)" % (id, sub["sill"], mth) + m = "Sill '%s' reset to %.3fm (%s)" % (ide, sub["sill"], mth) oslg.log(CN.WRN, m) # Attempt to reconcile "head", "sill" and/or "height". If successful, @@ -6114,8 +6474,8 @@ def addSubs(s=None, subs=[], clear=False, bound=False, realign=False, bfr=0.005) hght = sub["head"] - sub["sill"] if "height" in sub and abs(sub["height"] - hght) > CN.TOL2: - m1 = "Reset '%s' height %.3fm (%s)" % (id, sub["height"], mth) - m2 = "Height '%s' reset %.3fm (%s)" % (id, hght, mth) + m1 = "Reset '%s' height %.3fm (%s)" % (ide, sub["height"], mth) + m2 = "Height '%s' reset %.3fm (%s)" % (ide, hght, mth) oslg.log(CN.WRN, m1) oslg.log(CN.WRN, m2) @@ -6137,15 +6497,15 @@ def addSubs(s=None, subs=[], clear=False, bound=False, realign=False, bfr=0.005) if "height" in sub: sub["height"] = 0 if "width" in sub: sub["width" ] = 0 - m = "Skip: invalid '%s' head/height combo (%s)" % (id, mth) + m = "Skip: invalid '%s' head/height combo (%s)" % (ide, mth) oslg.log(CN.ERR, m) continue else: - m = "Reset '%s' height %.3fm (%s)" % (id, sub["height"], mth) + m = "Reset '%s' height %.3fm (%s)" % (ide, sub["height"], mth) oslg.log(CN.WRN, m) sub["sill" ] = sill sub["height"] = hght - m = "Height '%s' re(set) %.3fm (%s)" % (id, sub["height"], mth) + m = "Height '%s' re(set) %.3fm (%s)" % (ide, sub["height"], mth) oslg.log(CN.WRN, m) else: sub["sill"] = sill @@ -6169,15 +6529,15 @@ def addSubs(s=None, subs=[], clear=False, bound=False, realign=False, bfr=0.005) if "height" in sub: sub["height"] = 0 if "width" in sub: sub["width" ] = 0 - m = "Skip: invalid '%s' sill/height combo (%s)" % (id, mth) + m = "Skip: invalid '%s' sill/height combo (%s)" % (ide, mth) oslg.log(CN.ERR, m) continue else: - m = "Reset '%s' height %.3fm (%s)" % (id, sub["height"], mth) + m = "Reset '%s' height %.3fm (%s)" % (ide, sub["height"], mth) oslg.log(CN.WRN, m) sub["head" ] = head sub["height"] = hght - m = "Height '%s' reset to %.3fm (%s)" % (id, sub["height"], mth) + m = "Height '%s' reset to %.3fm (%s)" % (ide, sub["height"], mth) oslg.log(CN.WRN, m) else: sub["head"] = head @@ -6206,10 +6566,10 @@ def addSubs(s=None, subs=[], clear=False, bound=False, realign=False, bfr=0.005) if (sub["width"] < glass - CN.TOL2 or sub["width"] > max_width + CN.TOL2): - m = "Reset '%s' width %.3fm (%s)" % (id, sub["width"], mth) + m = "Reset '%s' width %.3fm (%s)" % (ide, sub["width"], mth) oslg.log(CN.WRN, m) sub["width"] = numpy.clip(sub["width"], glass, max_width) - m = "Width '%s' reset to %.3fm ()%s)" % (id, sub["width"], mth) + m = "Width '%s' reset to %.3fm ()%s)" % (ide, sub["width"], mth) oslg.log(CN.WRN, m) # Log/reset "count" if < 1 (or not an Integer) @@ -6220,24 +6580,24 @@ def addSubs(s=None, subs=[], clear=False, bound=False, realign=False, bfr=0.005) if sub["count"] < 1: sub["count"] = 1 - oslg.log(CN.WRN, "Reset '%s' count to min 1 (%s)" % (id, mth)) + oslg.log(CN.WRN, "Reset '%s' count to min 1 (%s)" % (ide, mth)) # Log/reset if left-sided buffer under min jamb position. if "l_buffer" in sub: if sub["l_buffer"] < min_ljamb - CN.TOL: - m = "Reset '%s' left buffer %.3fm (%s)" % (id, sub["l_buffer"], mth) + m = "Reset '%s' left buffer %.3fm (%s)" % (ide, sub["l_buffer"], mth) oslg.log(WRN, m) sub["l_buffer"] = min_ljamb - m = "Left buffer '%s' reset to %.3fm (%s)" % (id, sub["l_buffer"], mth) + m = "Left buffer '%s' reset to %.3fm (%s)" % (ide, sub["l_buffer"], mth) oslg.log(WRN, m) # Log/reset if right-sided buffer beyond max jamb position. if "r_buffer" in sub: if sub["r_buffer"] > max_rjamb - CN.TOL: - m = "Reset '%s' right buffer %.3fm (%s)" % (id, sub["r_buffer"], mth) + m = "Reset '%s' right buffer %.3fm (%s)" % (ide, sub["r_buffer"], mth) oslg.log(CN.WRN, m) sub["r_buffer"] = min_rjamb - m = "Right buffer '%s' reset to %.3fm (%s)" % (id, sub["r_buffer"], mth) + m = "Right buffer '%s' reset to %.3fm (%s)" % (ide, sub["r_buffer"], mth) oslg.log(CN.WRN, m) centre = mid_x @@ -6283,7 +6643,7 @@ def addSubs(s=None, subs=[], clear=False, bound=False, realign=False, bfr=0.005) if "l_buffer" in sub: if "centreline" in sub: - m = "Skip '%s' left buffer (vs centreline) (%s)" % (id, mth) + m = "Skip '%s' left buffer (vs centreline) (%s)" % (ide, mth) oslg.log(CN.WRN, m) else: x0 = sub["l_buffer"] - frame @@ -6291,7 +6651,7 @@ def addSubs(s=None, subs=[], clear=False, bound=False, realign=False, bfr=0.005) centre = x0 + w/2 elif "r_buffer" in sub: if "centreline" in sub: - m = "Skip '%s' right buffer (vs centreline) (%s)" % (id, mth) + m = "Skip '%s' right buffer (vs centreline) (%s)" % (ide, mth) oslg.log(CN.WRN, m) else: xf = max_x - sub["r_buffer"] + frame @@ -6307,15 +6667,15 @@ def addSubs(s=None, subs=[], clear=False, bound=False, realign=False, bfr=0.005) if "height" in sub: sub["height"] = 0 if "width" in sub: sub["width" ] = 0 - m = "Skip '%s': invalid (ratio) width/centreline (%s)" % (id, mth) + m = "Skip '%s': invalid (ratio) width/centreline (%s)" % (ide, mth) oslg.log(CN.ERR, m) continue if "width" in sub and abs(sub["width"] - wdth) > CN.TOL: - m = "Reset '%s' width (ratio) %.3fm (%s)" % (id, sub["width"], mth) + m = "Reset '%s' width (ratio) %.3fm (%s)" % (ide, sub["width"], mth) oslg.log(CN.WRN, m) sub["width"] = wdth - m = "Width (ratio) '%s' reset to %.3fm (%s)" % (id, sub["width"], mth) + m = "Width (ratio) '%s' reset to %.3fm (%s)" % (ide, sub["width"], mth) oslg.log(CN.WRN, m) if "width" not in sub: sub["width"] = wdth @@ -6329,7 +6689,7 @@ def addSubs(s=None, subs=[], clear=False, bound=False, realign=False, bfr=0.005) if "height" in sub: sub["height"] = 0 if "width" in sub: sub["width" ] = 0 - oslg.log(CN.ERR, "Skip: missing '%s' width (%s})" % (id, mth)) + oslg.log(CN.ERR, "Skip: missing '%s' width (%s})" % (ide, mth)) continue wdth = sub["width"] + frames @@ -6341,10 +6701,10 @@ def addSubs(s=None, subs=[], clear=False, bound=False, realign=False, bfr=0.005) offst = gap + wdth if "offset" in sub and abs(offst - sub["offset"]) > CN.TOL: - m = "Reset '%s' sub offset %.3fm (%s)" % (id, sub["offset"], mth) + m = "Reset '%s' sub offset %.3fm (%s)" % (ide, sub["offset"], mth) oslg.log(CN.WRN, m) sub["offset"] = offst - m = "Sub offset (%s) reset to %.3fm (%s)" % (id, sub["offset"], mth) + m = "Sub offset (%s) reset to %.3fm (%s)" % (ide, sub["offset"], mth) oslg.log(CN.WRN, m) if "offset" not in sub: sub["offset"] = offst @@ -6356,7 +6716,7 @@ def addSubs(s=None, subs=[], clear=False, bound=False, realign=False, bfr=0.005) if "l_buffer" in sub: if "centreline" in sub: - m = "Skip '%s' left buffer (vs centreline) (%s)" % (id, mth) + m = "Skip '%s' left buffer (vs centreline) (%s)" % (ide, mth) oslg.log(CN.WRN, m) else: x0 = sub["l_buffer"] - frame @@ -6364,7 +6724,7 @@ def addSubs(s=None, subs=[], clear=False, bound=False, realign=False, bfr=0.005) centre = x0 + w/2 elif "r_buffer" in sub: if "centreline" in sub: - m = "Skip '%s' right buffer (vs centreline) (%s)" % (id, mth) + m = "Skip '%s' right buffer (vs centreline) (%s)" % (ide, mth) oslg.log(WRN, m) else: xf = max_x - sub["r_buffer"] + frame @@ -6387,7 +6747,7 @@ def addSubs(s=None, subs=[], clear=False, bound=False, realign=False, bfr=0.005) # Generate sub(s). for i in range(sub["count"]): - name = "%s:%d" % (id, i) + name = "%s:%d" % (ide, i) fr = sub["frame"].frameWidth() if sub["frame"] else 0 vec = openstudio.Point3dVector() vec.append(openstudio.Point3d(pos, sub["head"], 0)) @@ -6486,11 +6846,11 @@ def grossRoofArea(spaces=[]) -> float: # Start with roof surfaces of occupied, conditioned spaces. for space in spaces: for roof in facets(space, "Outdoors", "RoofCeiling"): - id = roof.nameString() - if id in rfs: continue + ide = roof.nameString() + if ide in rfs: continue if not isRoof(roof): continue - rfs[id] = dict(m2=roof.grossArea(), m=space.multiplier()) + rfs[ide] = dict(m2=roof.grossArea(), m=space.multiplier()) # Roof surfaces of unoccupied, conditioned spaces above (e.g. plenums)? for space in spaces: @@ -6506,11 +6866,11 @@ def grossRoofArea(spaces=[]) -> float: if isUnconditioned(other): continue for roof in facets(other, "Outdoors", "RoofCeiling"): - id = roof.nameString() - if id in rfs: continue + ide = roof.nameString() + if ide in rfs: continue if not isRoof(roof): continue - rfs[id] = dict(m2=roof.grossArea(), m=other.multiplier()) + rfs[ide] = dict(m2=roof.grossArea(), m=other.multiplier()) # Roof surfaces of unoccupied, unconditioned spaces above (e.g. attics)? # @todo: recursive call for stacked spaces as atria (via AirBoundaries). @@ -6541,7 +6901,7 @@ def grossRoofArea(spaces=[]) -> float: ti = ti["t"] for roof in facets(other, "Outdoors", "RoofCeiling"): - id = roof.nameString() + ide = roof.nameString() if not isRoof(roof): continue rvi = ti * roof.vertices() @@ -6561,9 +6921,9 @@ def grossRoofArea(spaces=[]) -> float: m2 = m2.get() if m2 < CN.TOL2: continue - if id not in rfs: rfs[id] = dict(m2=0, m=other.multiplier()) + if ide not in rfs: rfs[ide] = dict(m2=0, m=other.multiplier()) - rfs[id]["m2"] += m2 + rfs[ide]["m2"] += m2 for rf in rfs.values(): rm2 += rf["m2"] * rf["m"] @@ -6571,13 +6931,13 @@ def grossRoofArea(spaces=[]) -> float: return rm2 -def getHorizontalRidges(roofs=[]) -> list: +def getHorizontalRidges(rufs=[]) -> list: """Identifies horizontal ridges along 2x sloped 'roof' surfaces (same space) - see 'isRoof'. Harmonized with OpenStudio's "alignZPrime" - see 'isSloped'. Args: - roofs (list): + rufs (list): A set of 'roof' openstudio.model.Surface instances. Returns: @@ -6591,15 +6951,15 @@ def getHorizontalRidges(roofs=[]) -> list: ridges = [] try: - roofs = list(roofs) + rufs = list(rufs) except: return ridges - roofs = [s for s in roofs if isinstance(s, openstudio.model.Surface)] - roofs = [s for s in roofs if isSloped(s)] - roofs = [s for s in roofs if isRoof(s)] + rufs = [s for s in rufs if isinstance(s, openstudio.model.Surface)] + rufs = [s for s in rufs if isSloped(s)] + rufs = [s for s in rufs if isRoof(s)] - for roof in roofs: + for roof in rufs: if not roof.space(): continue space = roof.space().get() @@ -6628,7 +6988,7 @@ def getHorizontalRidges(roofs=[]) -> list: # Links another roof (same space)? match = False - for ruf in roofs: + for ruf in rufs: if match: break if ruf == roof: continue if not ruf.space(): continue @@ -6710,10 +7070,10 @@ def toToplit(spaces=[], opts={}) -> list: spaces = [s for s in spaces if isinstance(s, openstudio.model.Space)] spaces = [s for s in spaces if s.partofTotalFloorArea()] spaces = [s for s in spaces if not isUnconditioned(s)] - spaces = [s for s in spaces if not isVestibule(s)] + spaces = [s for s in spaces if not areVestibules(s)] spaces = [s for s in spaces if roofs(s)] spaces = [s for s in spaces if s.floorArea() < 4 * w2] - spaces = sorted(spaces, key=floorArea(), reverse=True) + spaces = sorted(spaces, key=lambda s: s.floorArea(), reverse=True) if not spaces: return oslg.empty("spaces", mth, CN.WRN, []) # Unfenestrated spaces have no windows, glazed doors or skylights. By @@ -6730,7 +7090,7 @@ def toToplit(spaces=[], opts={}) -> list: afm2 = fm2 / len(spaces) unfen = [s for s in spaces if not isDaylit(s)] - unfen = sorted(unfen, key=floorArea(), reverse=True) + unfen = sorted(unfen, key=lambda s: s.floorArea(), reverse=True) # Target larger unfenestrated spaces, if sufficient in area. if unfen: @@ -6752,19 +7112,19 @@ def toToplit(spaces=[], opts={}) -> list: # Gather roof surfaces - possibly those of attics or plenums above. for s in spaces: - id = s.nameString() + ide = s.nameString() m2 = s.floorArea() for rf in roofs(s): - if id not in espaces: espaces[id] = dict(m2=m2, roofs=[]) - if rf not in espaces[id]["roofs"]: espaces[id]["roofs"].append(rf) + if ide not in espaces: espaces[ide] = dict(m2=m2, roofs=[]) + if rf not in espaces[ide]["roofs"]: espaces[ide]["roofs"].append(rf) # Priortize larger spaces. espaces = dict(sorted(espaces.items(), key=lambda s: s[1]["m2"], reverse=True)) # Prioritize larger roof surfaces. for s in espaces.values(): - s["roofs"] = sorted(s["roofs"], key=grossArea(), reverse=True) + s["roofs"] = sorted(s["roofs"], key=lambda s: s.grossArea(), reverse=True) # Single out largest roof in largest space, key when dealing with shared # attics or plenum roofs. @@ -6772,7 +7132,7 @@ def toToplit(spaces=[], opts={}) -> list: rfs = [ruf for ruf in s["roofs"] if ruf not in toits] if not rfs: continue - rfs = sorted(rfs, key=grossArea(), reverse=True) + rfs = sorted(rfs, key=lambda ruf: ruf.grossArea(), reverse=True) toits.append(rfs[0]) rooms.append(s) @@ -6782,6 +7142,1550 @@ def toToplit(spaces=[], opts={}) -> list: return rooms +def addSkyLights(spaces=[], opts=dict) -> float: + """Adds skylights to toplight selected OpenStudio (occupied, conditioned) + spaces, based on requested skylight area, or a skylight-to-roof ratio + (SRR%). If the user selects '0' m2 as the requested "area" (or '0' as the + requested "srr"), while setting the option "clear" as True, the method + simply purges all pre-existing roof fenestrated subsurfaces of selected + spaces, and exits while returning '0' (without logging an error or warning). + Pre-existing skylight wells are not cleared however. Pre-toplit spaces are + otherwise ignored. Boolean options "attic", "plenum", "sloped" and "sidelit" + further restrict candidate spaces to toplight. If applicable, options + "attic" and "plenum" add skylight wells. Option "patterns" restricts preset + skylight allocation layouts in order of preference; if left empty, all + preset patterns are considered, also in order of preference (see examples). + + Args: + spaces (list of openstudio.model.Space): + One or more spaces to toplight. + opts (dict): + Requested skylight attributes: + - "area" (float): overall skylight area. + - "srr" (float): skylight-to-roof ratio (0.00, 0.90] + - "size" (float): template skylight width/depth (min 0.4m) + - "frame" (openstudio.model.WindowPropertyFrameAndDivider): optional + - "clear" (bool): whether to first purge existing skylights + - "ration" (bool): finer selection of candidates to toplight + - "sidelit" (bool): whether to consider sidelit spaces + - "sloped" (bool): whether to consider sloped roof surfaces + - "plenum" (bool): whether to consider plenum wells + - "attic" (bool): whether to consider attic wells + + Returns: + float: 'Gross roof area' if successful (see logs if 0 m2) + + """ + mth = "osut.addSkyLights" + clear = True + srr = None + area = None + frame = None # FrameAndDivider object + f = 0.0 # FrameAndDivider frame width + gap = 0.1 # min 2" around well (2x == 4"), as well as max frame width + gap2 = 0.2 # 2x gap + gap4 = 0.4 # minimum skylight 16" width/depth (excluding frame width) + bfr = 0.005 # minimum array perimeter buffer (no wells) + w = 1.22 # default 48" x 48" skylight base + w2 = w * w # m2 + v = int("".join(openstudio.openStudioVersion().split("."))) + + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + # Excerpts of ASHRAE 90.1 2022 definitions: + # + # "ROOF": + # + # "the upper portion of the building envelope, including opaque areas and + # fenestration, that is horizontal or tilted at an angle of less than 60 + # degrees from horizontal. For the purposes of determining building + # envelope requirements, the classifications are defined as follows + # (inter alia): + # + # - attic and other roofs: all other roofs, including roofs with + # insulation ENTIRELY BELOW (inside of) the roof structure (i.e. + # attics, cathedral ceilings, and single-rafter ceilings), roofs with + # insulation both above and BELOW the roof structure, and roofs + # without insulation but excluding metal building roofs. [...]" + # + # "ROOF AREA, GROSS": + # + # "the area of the roof measured from the EXTERIOR faces of walls or from + # the centerline of party walls." + # + # + # For the simple case below (steep 4-sided hip roof, UNENCLOSED ventilated + # attic), 90.1 users typically choose between either: + # 1. modelling the ventilated attic explicitly, or + # 2. ignoring the ventilated attic altogether. + # + # If skylights were added to the model, option (1) would require one or more + # skylight wells (light shafts leading to occupied spaces below), with + # insulated well walls separating CONDITIONED spaces from an UNENCLOSED, + # UNCONDITIONED space (i.e. attic). + # + # Determining which roof surfaces (or which portion of roof surfaces) need + # to be considered when calculating "GROSS ROOF AREA" may be subject to some + # interpretation. From the above definitions: + # + # - the uninsulated, tilted hip-roof attic surfaces are considered "ROOF" + # surfaces, provided they 'shelter' insulation below (i.e. insulated + # attic floor). + # - however, only the 'projected' portion of such "ROOF" surfaces, i.e. + # areas between axes AA` and BB` (along exterior walls)) would be + # considered. + # - the portions above uninsulated soffits (illustrated on the right) + # would be excluded from the "GROSS ROOF AREA" as they are beyond the + # exterior wall projections. + # + # A B + # | | + # _________ + # / \ /| |\ + # / \ / | | \ + # /_ ________ _\ = > /_ | | _\ ... excluded portions + # | | + # |__________| + # . . + # A` B` + # + # If the unoccupied space (directly under the hip roof) were instead an + # INDIRECTLY-CONDITIONED plenum (not an attic), then there would be no need + # to exclude portions of any roof surface: all plenum roof surfaces (in + # addition to soffit surfaces) would need to be insulated). The method takes + # such circumstances into account, which requires vertically casting + # surfaces onto others, as well as overlap calculations. If successful, the + # method returns the "GROSS ROOF AREA" (in m2), based on the above rationale. + # + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + # Excerpts of similar NECB requirements (unchanged from 2011 through 2020): + # + # 3.2.1.4. 2). "The total skylight area shall be less than 2% of the GROSS + # ROOF AREA as determined in Article 3.1.1.6." (5% in earlier versions) + # + # 3.1.1.6. 5). "In the calculation of allowable skylight area, the GROSS + # ROOF AREA shall be calculated as the sum of the areas of insulated + # roof including skylights." + # + # There are NO additional details or NECB appendix notes on the matter. It + # is unclear if the NECB's looser definition of GROSS ROOF AREA includes + # (uninsulated) sloped roof surfaces above (insulated) flat ceilings (e.g. + # attics), as with 90.1. It would be definitely odd if it didn't. For + # instance, if the GROSS ROOF AREA were based on insulated ceiling surfaces, + # there would be a topological disconnect between flat ceiling and sloped + # skylights above. Should NECB users first 'project' (sloped) skylight rough + # openings onto flat ceilings when calculating SRR%? Without much needed + # clarification, the (clearer) 90.1 rules equally apply here to NECB cases. + + # If skylight wells are indeed required, well wall edges are always vertical + # (i.e. never splayed), requiring a vertical ray. + origin = openstudio.Point3d(0,0,0) + zenith = openstudio.Point3d(0,0,1) + ray = zenith - origin + + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + # Accept a single openStudio.model.Space (vs an array of spaces). + if isinstance(spaces, openstudio.model.Space): spaces = [spaces] + + try: + spaces = list(spaces) + except: + return oslg.mismatch("spaces", spaces, list, mth, CN.DBG, []) + + spaces = [s for s in spaces if isinstance(s, openstudio.model.Space)] + spaces = [s for s in spaces if s.partofTotalFloorArea()] + spaces = [s for s in spaces if not isUnconditioned(s)] + + if not spaces: + return oslg.empty("spaces", mth, CN.DBG, 0) + + mdl = spaces[0].model + + # Exit if mismatched or invalid options. + if not isinstance(opts, dict): + return oslg.mismatch("opts", opts, dict, mth, CN.DBG, 0) + + # Validate Frame & Divider object, if provided. + if "frame" in opts: + frame = opts["frame"] + + if isinstance(frame, openstudio.model.WindowPropertyFrameAndDivider): + if v < 321: frame = None + if round(frame.frameWidth(), 2) < 0: frame = None + if round(frame.frameWidth(), 2) > gap: frame = None + + if frame: + f = frame.frameWidth() + else: + oslg.log(CN.ERR, "Skip Frame&Divider object (%s)" % mth) + else: + frame = None + oslg.log(CN.ERR, "Skip invalid Frame&Divider object (%s)" % mth) + + # Validate skylight size, if provided. + if "size" in opts: + try: + w = float(opts["size"]) + except: + return oslg.mismatch("size", opts["size"], float, mth, CN.DBG, 0) + + if round(w, 2) < gap4: return oslg.invalid(size, mth, 0, CN.ERR, 0) + + w2 = w * w + + f2 = 2 * f + w0 = w + f2 + w02 = w0 * w0 + wl = w0 + gap + wl2 = wl * wl + + # Validate requested skylight-to-roof ratio (or overall area). + if "area" in opts: + try: + area = float(opts["area"]) + except: + return oslg.mismatch("area", opts["area"], float, mth, CN.DBG, 0) + + if area < 0: oslg.log(CN.WRN, "Area reset to 0.0 m2 (%s)" % mth) + + elif "srr" in opts: + try: + srr = float(opts["srr"]) + except: + return oslg.mismatch("srr", opts["srr"], float, mth, CN.DBG, 0) + + if srr < 0: + oslg.log(CN.WRN, "SRR (%.2f) reset to 0% (%s)" % (srr, mth)) + if srr > 0.90: + oslg.log(CN.WRN, "SRR (%.2f) reset to 90% (%s)" % (srr, mth)) + + srr = numpy.clip(srr, 0.00, 0.10) + else: + return oslg.hashkey("area", opts, "area", mth, CN.ERR, 0) + + # Validate purge request, if provided. + if "clear" in opts: + clear = opts["clear"] + + try: + clear = bool(clear) + except: + log(CN.WRN, "Purging existing skylights by default (%s)" % mth) + clear = True + + # Purge if requested. + if clear: + for s in roofs(spaces): + for sub in s.subSurfaces(): sub.remove() + + # Safely exit, e.g. if strictly called to purge existing roof subsurfaces. + if area and round(area, 2) == 0: return 0 + if srr and round(srr, 2) == 0: return 0 + + m2 = 0 # total existing skylight rough opening area + rm2 = grossRoofArea(spaces) # excludes e.g. overhangs + + # Tally existing skylight rough opening areas. + for space in spaces: + mx = space.multiplier() + + for roof in facets(space, "Outdoors", "RoofCeiling"): + for sub in roof.subSurfaces(): + if not isFenestration(sub): continue + + ide = sub.nameString() + xm2 = sub.grossArea() + + if sub.allowWindowPropertyFrameAndDivider(): + fd = sub.windowPropertyFrameAndDivider() + + if fd: + fd = fd.get() + fw = fd.frameWidth() + vec = offset(sub.vertices(), fw, 300) + aire = openstudio.getArea(vec) + + if aire: + xm2 = aire.get() + else: + m = "Skip '%s': Frame&Divider (%s)" % (ide, mth) + oslg.log(CN.ERR, m) + + + m2 += xm2 * sub.multiplier() * mx + + # Required skylight area to add. + sm2 = area if area else rm2 * srr - m2 + + # Warn/skip if existing skylights exceed or ~roughly match targets. + if round(sm2, 2) < round(w02, 2): + if m2 > 0: + oslg.log(CN.INF, "Skip: skylight area > request (%s)" % mth) + return rm2 + else: + oslg.log(CN.INF, "Requested skylight area < min size (%s)" % mth) + + elif 0.9 * round(rm2, 2) < round(sm2, 2): + oslg.log(CN.INF, "Skip: requested skylight area > 90% of GRA (%s)" % mth) + return rm2 + + if "ration" not in opts: opts["ration"] = True + + try: + opts["ration"] = bool(opts["ration"]) + except: + opts["ration"] = True + + # By default, seek ideal candidate spaces/roofs. Bail out if unsuccessful. + if opts["ration"] is True: + spaces = toToplit(spaces, opts) + if not spaces: return rm2 + + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + # The method seeks to insert a skylight array within the largest rectangular + # 'bounded box' that neatly 'fits' within a given roof surface. This equally + # applies to any vertically-cast overlap between roof and plenum (or attic) + # floor, which in turn generates skylight wells. Skylight arrays are + # inserted from left-to-right & top-to-bottom (as illustrated below), once a + # roof (or cast 3D overlap) is 'aligned' in 2D. + # + # Depending on geometric complexity (e.g. building/roof concavity, + # triangulation), the total area of bounded boxes may be significantly less + # than the calculated "GROSS ROOF AREA", which can make it challenging to + # attain the requested skylight area. If "patterns" are left unaltered, the + # method will select those that maximize the likelihood of attaining the + # requested target, to the detriment of spatial daylighting distribution. + # + # The default skylight module size is 1.22m x 1.22m (4' x 4'), which can be + # overridden by the user, e.g. 2.44m x 2.44m (8' x 8'). However, skylight + # sizes usually end up either contracted or inflated to exactly meet a + # request skylight area or SRR%, + # + # Preset skylight allocation patterns (in order of precedence): + # + # 1. "array" + # _____________________ + # | _ _ _ | - ?x columns ("cols") >= ?x rows (min 2x2) + # | |_| |_| |_| | - SRR ~5% (1.22m x 1.22m), as illustrated + # | | - SRR ~19% (2.44m x 2.44m) + # | _ _ _ | - +suitable for wide spaces (storage, retail) + # | |_| |_| |_| | - ~1.4x height + skylight width 'ideal' rule + # |_____________________| - better daylight distribution, many wells + # + # 2. "strips" + # _____________________ + # | _ _ _ | - ?x columns (min 2), 1x row + # | | | | | | | | - ~doubles %SRR ... + # | | | | | | | | - SRR ~10% (1.22m x ?1.22m), as illustrated + # | | | | | | | | - SRR ~19% (2.44m x ?1.22m) + # | |_| |_| |_| | - ~roof monitor layout + # |_____________________| - fewer wells + # + # 3. "strip" + # ____________________ + # | | - 1x column, 1x row (min 1x) + # | ______________ | - SRR ~11% (1.22m x ?1.22m) + # | | ............ | | - SRR ~22% (2.44m x ?1.22m), as illustrated + # | |______________| | - +suitable for elongated bounded boxes + # | | - 1x well + # |____________________| + # + # @todo: Support strips/strip patterns along ridge of paired roof surfaces. + layouts = ["array", "strips", "strip"] + patterns = [] + + # Validate skylight placement patterns, if provided. + if "patterns" in opts: + try: + opts["patterns"] = list(opts["patterns"]) + except: + oslg.mismatch("patterns", opts["patterns"], list, mth, CN.DBG) + + + for i, pattern in enumerate(opts["patterns"]): + pattern = oslg.trim(pattern).lower() + + if not pattern: + oslg.invalid("pattern %d" % (i+1), mth, 0, CN.ERR) + continue + + if pattern in layouts: patterns.append(pattern) + + if not patterns: patterns = layouts + + # The method first attempts to add skylights in ideal candidate spaces: + # - large roof surface areas (e.g. retail, classrooms ... not corridors) + # - not sidelit (favours core spaces) + # - having flat roofs (avoids sloped roofs) + # - neither under plenums, nor attics (avoids wells) + # + # This ideal (albeit stringent) set of conditions is "combo a". + # + # If the requested skylight area has not yet been achieved (after initially + # applying "combo a"), the method decrementally drops selection criteria and + # starts over, e.g.: + # - then considers sidelit spaces + # - then considers sloped roofs + # - then considers skylight wells + # + # A maximum number of skylights are allocated to roof surfaces matching a + # given combo, all the while giving priority to larger roof areas. An error + # message is logged if the target isn't ultimately achieved. + # + # Through filters, users may in advance restrict candidate roof surfaces: + # b. above occupied sidelit spaces (False restricts to core spaces) + # c. that are sloped (False restricts to flat roofs) + # d. above INDIRECTLY CONDITIONED spaces (e.g. plenums, uninsulated wells) + # e. above UNCONDITIONED spaces (e.g. attics, insulated wells) + filters = ["a", "b", "bc", "bcd", "bcde"] + + # Prune filters, based on user-selected options. + for opt in ["sidelit", "sloped", "plenum", "attic"]: + if opt not in opts: continue + if opts[opt] is True: continue + + if opt == "sidelit": + filters = [fil for fil in filters if "b" not in fil] + elif opt == "sloped": + filters = [fil for fil in filters if "c" not in fil] + elif opt == "plenum": + filters = [fil for fil in filters if "d" not in fil] + elif opt == "attic": + filters = [fil for fil in filters if "e" not in fil] + + filters = [fil for fil in filters if fil] # prune out any emptied pattern + filters = list(set(filters)) # ensure uniqueness + + # Remaining filters may be further pruned automatically after space/roof + # processing, depending on geometry, e.g.: + # - if there are no sidelit spaces: filter "b" will be pruned away + # - if there are no sloped roofs : filter "c" will be pruned away + # - if no plenums are identified : filter "d" will be pruned away + # - if no attics are identified : filter "e" will be pruned away + + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + # Break down spaces (and connected spaces) into groups. + sets = [] # collection of skylight arrays to deploy + rooms = {} # occupied CONDITIONED spaces to toplight + plenums = {} # unoccupied (INDIRECTLY-) CONDITIONED spaces above rooms + attics = {} # unoccupied UNCONDITIONED spaces above rooms + ceilings = {} # of occupied CONDITIONED space (if plenums/attics) + + # Candidate 'rooms' to toplit - excludes plenums/attics. + for space in spaces: + ide = space.nameString() + + if isDaylit(space, False, True, False): + oslg.log(CN.WRN, "%s is already toplit, skipping (%s)" % (ide, mth)) + continue + + # When unoccupied spaces are involved (e.g. plenums, attics), the + # occupied space (to toplight) may not share the same local + # transformation as its unoccupied space(s) above. Fetching site + # transformation. + t0 = transforms(space) + if not t0["t"]: continue + + # Calculate space height. + h = spaceHeight(space) + + if h < CN.TOL: + oslg.zero("%s height", mth, CN.ERR) + continue + + rooms[ide] = {} + rooms[ide]["space" ] = space + rooms[ide]["t0" ] = t0["t"] + rooms[ide]["m" ] = space.multiplier() + rooms[ide]["h" ] = h + rooms[ide]["roofs" ] = facets(space, "Outdoors", "RoofCeiling") + rooms[ide]["sidelit"] = isDaylit(space, True, False, False) + + # Fetch and process room-specific outdoor-facing roof surfaces. + # e.g. the most basic 'set' to track: + # - no skylight wells (i.e. no leader lines) + # - 1x skylight array per roof surface + # - no need to consider site transformation + for roof in rooms[space]["roofs"]: + if not isRoof(roof): continue + + vtx = roof.vertices() + box = boundedBox(vtx) + if not box: continue + + bm2 = openstudio.getArea(box) + if not bm2: continue + + bm2 = bm2.get() + if round(bm2, 2) < round(w02, 2): continue + + width = alignedWidth(box, True) + depth = alignedHeight(box, True) + if width < wl * 3: continue + if depth < wl: continue + + # A set is 'tight' if the area of its bounded box is significantly + # smaller than that of its roof. A set is 'thin' if the depth of + # its bounded box is (too) narrow. If either is True, some geometry + # rules may be relaxed to maximize allocated skylight area. Neither + # apply to cases with skylight wells. + tight = True if bm2 < roof.grossArea() / 2 else False + thin = True if round(depth, 2) < round(1.5 * wl, 2) else False + + set = {} + set["box" ] = box + set["bm2" ] = bm2 + set["tight" ] = tight + set["thin" ] = thin + set["roof" ] = roof + set["space" ] = space + set["m" ] = space.multiplier() + set["sidelit"] = rooms[space]["sidelit"] + set["t0" ] = rooms[space]["t0"] + set["t" ] = openstudio.Transformation.alignFace(vtx) + sets.append(set) + + # Process outdoor-facing roof surfaces of plenums and attics above. + for ide, room in rooms.items(): + t0 = room["t0"] + space = room["space"] + rufs = roofs(space) - room["roofs"] + + for ruf in rufs: + id0 = ruf.nameString() + vtx = ruf.vertices() + if not isRoof(ruf): continue + + espace = ruf.space() + if not espace: continue + + espace = espace.get() + if espace.partofTotalFloorArea(): continue + + idx = espace.nameString() + mx = espace.multiplier() + + if mx != space.multiplier(): + m = "%s vs %s - multiplier mismatch (%s)" % (ide, idx, mth) + log(CN.ERR, m) + continue + + ti = transforms(espace) + if not ti["t"]: continue + + ti = ti["t"] + rpts = ti * vtx + + # Process occupied room ceilings, as 1x or more are overlapping roof + # surfaces above. Vertically cast, then fetch overlap. + for tile in facets(space, "Surface", "RoofCeiling"): + idee = tile.nameString() + tpts = t0 * tile.vertices() + ci0 = cast(tpts, rpts, ray) + if not ci0: continue + + olap = overlap(rpts, ci0) + if not olap: continue + + om2 = openstudio.getArea(olap) + if not om2: continue + + om2 = om2.get() + if round(om2, 2) < round(w02, 2): continue + + box = boundedBox(olap) + if not box: continue + + # Adding skylight wells (plenums/attics) is contingent to safely + # linking new base roof 'inserts' (as well as new ceiling ones) + # through 'leader lines'. This requires an offset to ensure no + # conflicts with roof or (ceiling) tile edges. + # + # @todo: Expand the method to factor in cases where simple + # 'side' cutouts can be supported (no need for leader + # lines), e.g. skylight strips along roof ridges. + box = offset(box, -gap, 300) + if not box: continue + + bm2 = openstudio.getArea(box) + if not bm2: continue + + bm2 = bm2.get() + if round(bm2, 2) < round(wl2, 2): continue + + width = alignedWidth(box, True) + depth = alignedHeight(box, True) + if width < wl * 3: continue + if depth < wl * 2: continue + + # Vertically cast box onto tile below. + cbox = cast(box, tpts, ray) + if not cbox: continue + + cm2 = openstudio.getArea(cbox) + if not cm2: continue + + cm2 = cm2.get() + box = ti.inverse() * box + cbox = t0.inverse() * cbox + + if idee not in ceilings: + floor = tile.adjacentSurface() + if not floor: + oslg.log(CN.ERR, "%s adjacent floor? (%s)" % (idee, mth)) + continue + + floor = floor.get() + if not floor.space(): + oslg.log(CN.ERR, "%s space? (%s)" % (idee, mth)) + continue + + espce = floor.space().get() + if espce != espace: + ido = espce.nameString() + oslg.log(CN.ERR, "%s != %s? (%s)" % (ido, idx, mth)) + continue + + ceilings[idee] = {} # idee: ceiling surface ID + ceilings[idee]["clng" ] = tile # ceiling surface itself + ceilings[idee]["id" ] = ide # its space's ID + ceilings[idee]["space"] = space # its space + ceilings[idee]["floor"] = floor # adjacent floor + ceilings[idee]["roofs"] = [] # collection of roofs above + + ceilings[idee]["roofs"].append(ruf) + + # Skylight set key:values are more detailed with suspended + # ceilings. The overlap ("olap") remains in 'transformed' site + # coordinates (with regards to the roof). The "box" polygon + # reverts to attic/plenum space coordinates, while the "cbox" + # polygon is reset with regards to the occupied space + # coordinates. + set = {} + set["olap" ] = olap + set["box" ] = box + set["cbox" ] = cbox + set["om2" ] = om2 + set["bm2" ] = bm2 + set["cm2" ] = cm2 + set["tight" ] = False + set["thin" ] = False + set["roof" ] = ruf + set["space" ] = space + set["m" ] = space.multiplier() + set["clng" ] = tile + set["t0" ] = t0 + set["ti" ] = ti + set["t" ] = openstudio.Transformation.alignFace(vtx) + set["sidelit"] = room["sidelit"] + + if isUnconditioned(espace): # e.g. attic + if idx not in attics: # idx = espace.nameString() + attics[idx] = {} + attics[idx]["space"] = espace + attics[idx]["ti" ] = ti + attics[idx]["m" ] = mx + attics[idx]["bm2" ] = 0 + attics[idx]["roofs"] = [] + + attics[idx]["bm2" ] += bm2 + attics[idx]["roofs"].append(ruf) + + set["attic"] = espace + + ceilings[idee]["attic"] = espace # adjacent attic (floor) + else: # e.g. plenum + if idx not in plenums: + plenums[idx] = {} + plenums[idx]["space"] = espace + plenums[idx]["ti" ] = ti + plenums[idx]["m" ] = mx + plenums[idx]["bm2" ] = bm2 + plenums[idx]["roofs"] = [] + + plenums[idx]["bm2" ] += bm2 + plenums[idx]["roofs"].append(ruf) + + set["plenum"] = espace + + ceilings[idee]["plenum"] = espace # adjacent plenum (floor) + + sets.append(set) + break # only 1x unique ruf/ceiling pair. + + # Ensure uniqueness of plenum roofs. + for attic in attics.values(): + attic["roofs" ] = list(set(attic["roofs"])) + attic["ridges"] = horizontalRidges(attic["roofs"]) # @todo + + for plenum in plenums.values(): + plenum["roofs" ] = list(set(plenum["roofs"])) + plenum["ridges"] = horizontalRidges(plenum["roofs"]) # @todo + + # Regardless of the selected skylight arrangement pattern, the solution only + # considers attic/plenum sets that can be successfully linked to leader line + # anchors, for both roof and ceiling surfaces. First, attic/plenum roofs. + for greniers in [attics, plenums]: + k = "attic" if greniers == attics else "plenum" + + for grenier in greniers.values(): + for roof in grenier["roofs"]: + sts = sets + sts = [st for st in sts if k in st] + sts = [st for st in sts if "space" in st] + sts = [st for st in sts if "box" in st] + sts = [st for st in sts if "bm2" in st] + sts = [st for st in sts if "roof" in st] + + sts = [st for st in sts if st[k ] == grenier["space"]] + sts = [st for st in sts if st["roof"] == roof] + if not sts: continue + + sts = sorted(sts, key=lambda st: st["bm2"], reverse=True) + genAnchors(roof, sts, "box") + + # Delete voided sets. + sets = [set for set in sets if "void" not in set] + + # Repeat leader line loop for ceilings. + for ceiling in ceilings.values(): + k = "attic" if "attic" in ceiling else "plenum" + if k not in ceiling: continue + + clng = ceiling["cnlg" ] # ceiling surface + space = ceiling["space"] # its space + espace = ceiling[k] # adjacent (unoccupied) space above + + if "roofs" not in ceiling: continue + if ceiling["ide"] not in rooms: continue + + stz = [] + + for roof in ceiling["roofs"]: + sts = sets + + sts = [st for st in sts if k in st] + sts = [st for st in sts if "cbox" in st] + sts = [st for st in sts if "cm2" in st] + sts = [st for st in sts if "roof" in st] + sts = [st for st in sts if "clng" in st] + sts = [st for st in sts if "space" in st] + + sts = [st for st in sts if st[k ] == espace] + sts = [st for st in sts if st["roof" ] == roof] + sts = [st for st in sts if st["clng" ] == clng] + sts = [st for st in sts if st["space"] == space] + if len(sts) != 1: continue + + stz.append(sts[0]) + + if not stz: continue + + stz = sorted(stz, key=lambda st: st["cm2"], reverse=True) + genAnchors(tile, stz, "cbox") + + # Delete voided sets. + sets = [set for set in sets if "void" not in set] + if not sets: return oslg.empty("sets", mth, CN.WRN, rm2) + + # Sort sets, from largest to smallest bounded box area. + sets = sorted(sets, key=lambda st: st["bm2"] * st["m"], reverse=True) + + # Any sidelit and/or sloped roofs being targeted? + # @todo: enable double-ridged, sloped roofs have double-sloped + # skylights/wells (patterns "strip"/"strips"). + sidelit = any(set["sidelit"] for set in sets) + sloped = any(set["sloped" ] for set in sets) + + # Average sandbox area + revised 'working' SRR%. + sbm2 = sum(set.get("bm2", 0) for set in sets) + avm2 = sbm2 / len(sets) + srr2 = sm2 / len(sets) / avm2 + + # Precalculate skylight rows + cols, for each selected pattern. In the case + # of 'cols x rows' arrays of skylights, the method initially overshoots + # with regards to 'ideal' skylight placement, e.g.: + # + # aceee.org/files/proceedings/2004/data/papers/SS04_Panel3_Paper18.pdf + # + # Skylight areas are subsequently contracted to strictly meet the target. + for i, set in enumerate(sets): + thin = set["thin"] + tight = set["tight"] + factor = 1.75 if tight else 1.25 + well = "clng" in set + space = set["space"] + room = rooms[space.nameString()] + h = room["h"] + width = alignedWidth( set["box"], True) + depth = alignedHeight(set["box"], True) + barea = set["om2"] if "om2" in set else set["bm2"] + rtio = barea / avm2 + skym2 = srr2 * barea * rtio + + # Flag set if too narrow/shallow to hold a single skylight. + if well: + if round(width, 2) < round(wl, 2): + oslg.log(CN.WRN, "set #{i+1} well: Too narrow (%s)" % mth) + set["void"] = True + continue + + if round(depth, 2) < round(wl, 2): + oslg.log(CN.WRN, "set #{i+1} well: Too shallow (%s)" % mth) + set["void"] = True + continue + else: + if round(width, 2) < round(w0, 2): + oslg.log(CN.WRN, "set #{i+1}: Too narrow (%s)" % mth) + set["void"] = True + continue + + if round(depth, 2) < round(w0, 2): + oslg.log(CN.WRN, "set #{i+1}: Too shallow (%s)" % mth) + set["void"] = True + continue + + # Estimate number of skylight modules per 'pattern'. Default spacing + # varies based on bounded box size (i.e. larger vs smaller rooms). + for pattern in patterns: + cols = 1 + rows = 1 + wx = w0 + wy = w0 + wxl = wl if well else None + wyl = wl if well else None + dX = None + dY = None + + if pattern == "array": # min 2x cols x min 2x rows + cols = 2 + rows = 2 + if thin: continue + + if tight: + sp = 1.4 * h / 2 + lx = width - cols * wx + ly = depth - rows * wy + if round(lx, 2) < round(sp, 2): continue + if round(ly, 2) < round(sp, 2): continue + + cols = int(round((width - wx) / (wx + sp)), 2) + 1 + rows = int(round((depth - wy) / (wy + sp)), 2) + 1 + if cols < 2: continue + if rows < 2: continue + + dX = bfr + f + dY = bfr + f + else: + sp = 1.4 * h + + if well: + lx = (width - cols * wxl) / cols + ly = (depth - rows * wyl) / rows + else: + lx = (width - cols * wx) / cols + ly = (depth - rows * wy) / rows + + if round(lx, 2) < round(sp, 2): continue + if round(ly, 2) < round(sp, 2): continue + + if well: + cols = (width / (wxl + sp)).round(2).to_i + rows = (depth / (wyl + sp)).round(2).to_i + else: + cols = (width / (wx + sp)).round(2).to_i + rows = (depth / (wy + sp)).round(2).to_i + + if cols < 2: continue + if rows < 2: continue + + if well: + ly = (depth - rows * wyl) / rows + else: + ly = (depth - rows * wy) / rows + + dY = ly / 2 + + # Default allocated skylight area. If undershooting, inflate + # skylight width/depth (with reduced spacing). For geometrically + # -constrained cases, undershooting means not reaching 1.75x the + # required target. Otherwise, undershooting means not reaching + # 1.25x the required target. Any consequent overshooting is + # later corrected. + tm2 = wx * cols * wy * rows + + # Inflate skylight width/depth (and reduce spacing) to reach + # target. + if round(tm2, 2) < factor * round(skym2, 2): + ratio2 = 1 + (factor * skym2 - tm2) / tm2 + ratio = math.sqrt(ratio2) + + sp = wl + wx *= ratio + wy *= ratio + + if well: + wxl = wx + gap + wyl = wy + gap + + if tight: + lx = (width - 2 * (bfr + f) - cols * wx) / (cols - 1) + ly = (depth - 2 * (bfr + f) - rows * wy) / (rows - 1) + lx = sp if round(lx, 2) < round(sp, 2) else lx + ly = sp if round(ly, 2) < round(sp, 2) else ly + wx = (width - 2 * (bfr + f) - (cols - 1) * lx) / cols + wy = (depth - 2 * (bfr + f) - (rows - 1) * ly) / rows + else: + if well: + lx = (width - cols * wxl) / cols + ly = (depth - rows * wyl) / rows + lx = sp if round(lx, 2) < round(sp, 2) else lx + ly = sp if round(ly, 2) < round(sp, 2) else ly + wxl = (width - cols * lx) / cols + wyl = (depth - rows * ly) / rows + wx = wxl - gap + wy = wyl - gap + ly = (depth - rows * wyl) / rows + else: + lx = (width - cols * wx) / cols + ly = (depth - rows * wy) / rows + lx = sp if round(lx, 2) < round(sp, 2) else lx + ly = sp if round(ly, 2) < round(sp, 2) else ly + wx = (width - cols * lx) / cols + wy = (depth - rows * ly) / rows + ly = (depth - rows * wy) / rows + + dY = ly / 2 + + elif pattern == "strips": # min 2x cols x 1x row + cols = 2 + + if tight: + sp = h / 2 + dX = bfr + f + lx = width - cols * wx + if round(lx, 2) < round(sp, 2): continue + + cols = int(round((width - wx) / (wx + sp)), 2) + 1 + if cols < 2: continue + + if thin: + dY = bfr + f + wy = depth - 2 * dY + if round(wy, 2) < gap4: continue + else: + ly = depth - wy + if round(ly, 2) < round(wl, 2): continue + + dY = ly / 2 + else: + sp = h + + if well: + lx = (width - cols * wxl) / cols + if round(lx, 2) < round(sp, 2): continue + + cols = int(round(width / (wxl + sp), 2)) + if cols < 2: continue + + ly = depth - wyl + dY = ly / 2 + if round(ly, 2) < round(wl, 2): continue + else: + lx = (width - cols * wx) / cols + if round(lx, 2) < round(sp, 2): continue + + cols = int(round(width / (wx + sp), 2)) + if cols < 2: continue + + if thin: + dY = bfr + f + wy = depth - 2 * dY + if round(wy, 2) < gap4: continue + else: + ly = depth - wy + if round(ly, 2) < round(wl, 2): continue + + dY = ly / 2 + + tm2 = wx * cols * wy + + # Inflate skylight depth to reach target. + if round(tm2, 2) < factor * round(skym2, 2): + sp = wl + + # Skip if already thin. + if not thin: + ratio2 = 1 + (factor * skym2 - tm2) / tm2 + + wy *= ratio2 + + if well: + wyl = wy + gap + ly = depth - wyl + ly = sp if round(ly, 2) < round(sp, 2) else ly + wyl = depth - ly + wy = wyl - gap + else: + ly = depth - wy + ly = sp if round(ly, 2) < round(sp, 2) else ly + wy = depth - ly + + dY = ly / 2 + + tm2 = wx * cols * wy + + # Inflate skylight width (and reduce spacing) to reach target. + if round(tm2, 2) < factor * round(skym2, 2): + ratio2 = 1 + (factor * skym2 - tm2) / tm2 + + wx *= ratio2 + if well: wxl = wx + gap + + if tight: + lx = (width - 2 * (bfr + f) - cols * wx) / (cols - 1) + lx = sp if round(lx, 2) < round(sp, 2) else lx + wx = (width - 2 * (bfr + f) - (cols - 1) * lx) / cols + else: + if well: + lx = (width - cols * wxl) / cols + lx = sp if round(lx, 2) < round(sp, 2) else lx + wxl = (width - cols * lx) / cols + wx = wxl - gap + else: + lx = (width - cols * wx) / cols + lx = sp if round(lx, 2) < round(sp, 2) else lx + wx = (width - cols * lx) / cols + + else: # "strip" 1 (long?) row x 1 column + if tight: + sp = gap4 + dX = bfr + f + wx = width - 2 * dX + if round(wx, 2) < round(sp, 2): continue + + if thin: + dY = bfr + f + wy = depth - 2 * dY + if round(wy, 2) < round(sp, 2): continue + else: + ly = depth - wy + dY = ly / 2 + if round(ly, 2) < round(sp, 2): continue + else: + sp = wl + lx = width - wxl if well else width - wx + ly = depth - wyl if well else depth - wy + dY = ly / 2 + if round(lx, 2) < round(sp, 2): continue + if round(ly, 2) < round(sp, 2): continue + + tm2 = wx * wy + + # Inflate skylight width (and reduce spacing) to reach target. + if round(tm2, 2) < factor * round(skym2, 2): + if not tight: + ratio2 = 1 + (factor * skym2 - tm2) / tm2 + + wx *= ratio2 + + if well: + wxl = wx + gap + lx = width - wxl + lx = sp if round(lx, 2) < round(sp, 2) else lx + wxl = width - lx + wx = wxl - gap + else: + lx = width - wx + lx = sp if round(lx, 2) < round(sp, 2) else lx + wx = width - lx + + tm2 = wx * wy + + # Inflate skylight depth to reach target. Skip if already tight thin. + if round(tm2, 2) < factor * round(skym2, 2): + if not thin: + ratio2 = 1 + (factor * skym2 - tm2) / tm2 + + wy *= ratio2 + + if well: + wyl = wy + gap + ly = depth - wyl + ly = sp if ly.round(2) < sp.round(2) else ly + wyl = depth - ly + wy = wyl - gap + else: + ly = depth - wy + ly = sp if ly.round(2) < sp.round(2) else ly + wy = depth - ly + + dY = ly / 2 + + st = {} + st["tight"] = tight + st["cols" ] = cols + st["rows" ] = rows + st["wx" ] = wx + st["wy" ] = wy + st["wxl" ] = wxl + st["wyl" ] = wyl + + if dX: st["dX"] = dX + if dY: st["dY"] = dY + + set[pattern] = st + + if not any(pattern in set for pattern in patterns): set["void"] = True + + # Delete voided sets. + sets = [set for set in sets if "void" not in set] + if not sets: return oslg.empty("sets (2)", mth, CN.WRN, rm2) + + # Final reset of filters. + if not sidelit: filters = [fil.replace("b", "") for fil in filters] + if not sloped: filters = [fil.replace("c", "") for fil in filters] + if not plenums: filters = [fil.replace("d", "") for fil in filters] + if not attics: filters = [fil.replace("e", "") for fil in filters] + + filters = [fil for fil in filters if fil] # remove any empty filter strings + filters = list(set(filters)) # ensure uniqueness + + # Initialize skylight area tally (to increment). + skm2 = 0 + + # Assign skylight pattern. + for filter in filters: + if round(skm2, 2) >= round(sm2, 2): continue + + dm2 = sm2 - skm2 # differential (remaining skylight area to meet). + sts = [st for st in sets if "pattern" not in st] + + if "a" in filter: + # Start with the default (ideal) allocation selection: + # - large roof surface areas (e.g. retail, classrooms not corridors) + # - not sidelit (favours core spaces) + # - having flat roofs (avoids sloped roofs) + # - not under plenums, nor attics (avoids wells) + sts = [st for st in sts if not st["sidelit"]] + sts = [st for st in sts if not st["sloped" ]] + sts = [st for st in sts if "clng" not in st] + else: + if "b" not in filter: sts = [st for st in sts if not st["sidelit"]] + if "c" not in filter: sts = [st for st in sts if not st["sloped" ]] + if "d" not in filter: sts = [st for st in sts if "plenum" not in st] + if "e" not in filter: sts = [st for st in sts if "attic" not in st] + + if not sts: continue + + # Tally precalculated skylights per pattern (once filtered). + fpm2 = {} + + for pattern in patterns: + for st in sts: + if pattern not in st: continue + + cols = st[pattern]["cols"] + rows = st[pattern]["rows"] + wx = st[pattern]["wx" ] + wy = st[pattern]["wy" ] + + if pattern not in fpm2: fpm2[pattern] = dict(m2=0, tight=False) + + fpm2[pattern]["m2"] += st["m"] * wx * wy * cols * rows + if st["tight"]: fpm2[pattern]["tight"] = True + + pattern = None + if not fpm2: continue + + # Favour (large) arrays if meeting residual target, unless constrained. + if "array" in fpm2: + if round(fpm2["array"]["m2"], 2) >= round(dm2, 2): + if not fpm2["tight"]: pattern = "array" + + if not pattern: + fpm2 = dict(sorted(f2.items(), key=lambda f2: f2[1]["m2"])) + mnM2 = fpm2.values()[ 0]["m2"] + mxM2 = fpm2.values()[-1]["m2"] + + if mnM2.round(2) >= dm2.round(2): + # If not large array, then retain pattern generating smallest + # skylight area if ALL patterns >= residual target + # (deterministic sorting). + fpm2 = dict(fpm2.items(), key=lambda f2: round(f2, 2) == round(mnM2, 2)) + + if "array" in fpm2: + pattern = "array" + elif "strips" in fpm2: + pattern = "strips" + else: # "strip" in fpm2 + pattern = "strip" + else: + # Pick pattern offering greatest skylight area + # (deterministic sorting). + fpm2 = dict(fpm2.items(), key=lambda f2: round(f2, 2) == round(mxM2, 2)) + + if "strip" in fpm2: + pattern = "strip" + elif "strips" in fpm2: + pattern = "strips" + else: # "array" in fpm2 + pattern = "array" + + skm2 += fpm2[pattern]["m2"] + + # Update matching sets. + for st in sts: + for set in sets: + if pattern not in set: continue + if st["roof"] != set["roof"]: continue + if not areSame(st["box"], set["box"]): continue + + if "clng" in st: + if not "clng" in set: continue + if st["clng"] != set["clng"]: continue + + set["pattern"] = pattern + set["cols" ] = set[pattern]["cols"] + set["rows" ] = set[pattern]["rows"] + set["w" ] = set[pattern]["wx" ] + set["d" ] = set[pattern]["wy" ] + set["w0" ] = set[pattern]["wxl" ] + set["d0" ] = set[pattern]["wyl" ] + + if set[pattern]["dX"]: set["dX"] = set[pattern]["dX"] + if set[pattern]["dY"]: set["dY"] = set[pattern]["dY"] + + # Delete incomplete sets (same as rejected if 'voided'). + sets = [set for set in sets if "void" not in set] + sets = [set for set in sets if "pattern" in set] + if not sets: return oslg.empty("sets (3)", mth, CN.WRN, rm2) + + # Skylight size contraction if overshot (e.g. scale down by -13% if > +13%). + # Applied on a surface/pattern basis: individual skylight sizes may vary + # from one surface to the next, depending on respective patterns. + + # First, skip whole sets altogether if their total m2 < (skm2 - sm2). Only + # considered if significant discrepancies vs average set skylight m2. + sbm2 = 0 + + for set in sets: + sbm2 += set["cols"] * set["w"] * set["rows"] * set["d"] * set["m"] + + avm2 = sbm2 / len(sets) + + if round(skm2, 2) > round(sm2, 2): + sets.reverse() + + for set in sets: + if round(skm2, 2) <= round(sm2, 2): break + + stm2 = set["cols"] * set["w"] * set["rows"] * set["d"] * set["m"] + if round(stm2, 2) >= round(0.75 * avm2, 2): continue + if round(stm2, 2) >= round(skm2 - sm2, 2): continue + + skm2 -= stm2 + set["void"] = True + + sets.reverse() + + sets = [set for set in sets if "void" not in set] + if not sets: return oslg.empty("sets (4)", mth, CN.WRN, rm2) + + # Size contraction: round 1: low-hanging fruit. + if round(skm2, 2) > round(sm2, 2): + ratio2 = 1 - (skm2 - sm2) / skm2 + ratio = math.sqrt(ratio2) + + for set in sets: + am2 = set["cols"] * set["w"] * set["rows"] * set["d"] * set["m"] + xr = set["w"] + yr = set["d"] + + if xr > w0: + xr = w0 if xr * ratio < w0 else xr * ratio + + if yr > w0: + yr = w0 if yr * ratio < w0 else yr * ratio + + xm2 = set["cols"] * xr * set["rows"] * yr * set["m"] + if round(xm2, 2) == round(am2, 2): continue + + set["dY"] += (set["d"] - yr) / 2 + if "dX" in set: set["dX"] += (set["w"] - xr) / 2 + + set["w" ] = xr + set["d" ] = yr + set["w0"] = set["w"] + gap + set["d0"] = set["d"] + gap + + skm2 -= (am2 - xm2) + + # Size contraction: round 2: prioritize larger sets. + adm2 = 0 + + for set in sets: + if round(set["w"], 2) <= w0: continue + if round(set["d"], 2) <= w0: continue + + adm2 += set["cols"] * set["w"] * set["rows"] * set["d"] * set["m"] + + if round(skm2, 2) > round(sm2, 2) and round(adm2, 2) > round(sm2, 2): + ratio2 = 1 - (adm2 - sm2) / adm2 + ratio = math.sqrt(ratio2) + + for set in sets: + if round(set["w"], 2) <= w0: continue + if round(set["d"], 2) <= w0: continue + + am2 = set["cols"] * set["w"] * set["rows"] * set["d"] * set["m"] + xr = set["w"] + yr = set["d"] + + if xr > w0: + xr = w0 if xr * ratio < w0 else xr * ratio + + if yr > w0: + yr = yw0 if r * ratio < w0 else yr * ratio + + xm2 = set["cols"] * xr * set["rows"] * yr * set["m"] + if round(xm2, 2) == round(am2, 2): continue + + set["dY"] += (set["d"] - yr) / 2 + if "dX" in set: set["dX"] += (set["w"] - xr) / 2 + + set["w" ] = xr + set["d" ] = yr + set["w0"] = set["w"] + gap + set["d0"] = set["d"] + gap + + skm2 -= (am2 - xm2) + adm2 -= (am2 - xm2) + + # Size contraction: round 3: Resort to sizes < requested w0. + if round(skm2, 2) > round(sm2, 2): + ratio2 = 1 - (skm2 - sm2) / skm2 + ratio = math.sqrt(ratio2) + + for set in sets: + if round(skm2, 2) <= round(sm2, 2): break + + am2 = set["cols"] * set["w"] * set["rows"] * set["d"] * set["m"] + xr = set["w"] + yr = set["d"] + + if xr > gap4: + xr = gap4 if xr * ratio < gap4 else xr * ratio + + if yr > gap4: + yr = gap4 if yr * ratio < gap4 else yr * ratio + + xm2 = set["cols"] * xr * set["rows"] * yr * set["m"] + if round(xm2, 2) == round(am2, 2): continue + + set["dY"] += (set["d"] - yr) / 2 + if "dX" in set: set["dX"] += (set["w"] - xr) / 2 + + set["w" ] = xr + set["d" ] = yr + set["w0"] = set["w"] + gap + set["d0"] = set["d"] + gap + + skm2 -= (am2 - xm2) + + # Log warning if unable to entirely contract skylight dimensions. + if round(skm2, 2) > round(sm2, 2): + oslg.log(CN.WRN, "Skylights slightly oversized (%s)" % (mth)) + + # Generate skylight well vertices for roofs, attics & plenums. + for greniers in [attics, plenums]: + k = "attic" if greniers == attics else "plenum" + + for grenier in greniers.values(): + for roof in grenier["roofs"]: + sts = sets + sts = [st for st in sts if "clng" in st] + sts = [st for st in sts if k in st] + sts = [st for st in sts if "ld" in st] + sts = [st for st in sts if "space" in st] + sts = [st for st in sts if "roof" in st] + sts = [st for st in sts if "pattern" in st] + sts = [st for st in sts if st["pattern"] in st] + sts = [st for st in sts if st["space" ] in rooms] + sts = [st for st in sts if id(roof) in st["ld"]] + + sts = [st for st in sts if st[k ] == grenier["space"]] + sts = [st for st in sts if st["roof"] == roof] + if not sts: continue + + # If successful, 'genInserts' returns extended ROOF surface + # vertices, including leader lines to support cutouts. The + # method also generates new roof inserts. See key:value pair + # "vts". The FINAL go/no-go is contingent to successfully + # inserting corresponding room ceiling inserts (vis-à-vis + # attic/plenum floor below). + vz = genInserts(roof, sts) + if not vz: continue + + roof.setVertices(vz) + + # Repeat for ceilings below attic/plenum floors. + for ceiling in ceilings.values(): + k = "attic" if "attic" in ceiling else "plenum" + greniers = attics if k == "attic" else plenums + + if k not in ceiling: continue + if "floor" not in ceiling: continue + if "clng" not in ceiling: continue + if "space" not in ceiling: continue + + espace = ceiling[k ] # (unoccupied) space above ceiling + floor = ceiling["floor"] # adjacent floor above + clng = ceiling["cnlg" ] # ceiling surface + space = ceiling["space"] # its space + idx = espace.nameString() + ide = space.nameString() + if ide not in rooms: continue + if idx not in greniers: continue + + room = rooms[ide] + grenier = greniers[idx] + ti = grenier["ti"] + t0 = room["t0"] + stz = [] + + for roof in ceiling["roofs"]: + sts = sets + sts = [st for st in sts if "clng" in st] + sts = [st for st in sts if k in st] + sts = [st for st in sts if "space" in st] + sts = [st for st in sts if "roof" in st] + sts = [st for st in sts if "pattern" in st] + sts = [st for st in sts if "cm2" in st] + sts = [st for st in sts if "vts" in st] + sts = [st for st in sts if "vtx" in st] + sts = [st for st in sts if "ld" in st] + sts = [st for st in sts if id(roof) in st["ld"]] + sts = [st for st in sts if id(clng) in st["ld"]] + sts = [st for st in sts if st["space"] in rooms] + + sts = [st for st in sts if st["clng"] == clng] + sts = [st for st in sts if st["roof"] == roof] + sts = [st for st in sts if st[k ] == espace] + if len(sts) != 1: continue + + stz.append(sts)[0] + + if not stz: continue + + # Add new roof inserts & skylights for the (now) toplit space. + for i, st in enumerate(stz): + sub = {} + sub["type"] = "Skylight" + sub["sill"] = gap / 2 + if frame: sub["frame"] = frame + + for ids, vt in st["vts"].items(): + roof = openstudio.model.Surface(t0.inverse() * (ti * vt), mdl) + roof.setSpace(space) + roof.setName("%s:%s" % (ids, ide)) + + # Generate well walls. + vX = cast(roof, clng, ray) + s0 = segments(t0 * roof.vertices()) + sX = segments(t0 * vX) + + for j, sg in enumerate(s0): + sg0 = list(sg) + sgX = list(sX[j]) + vec = openstudio.Point3dVector() + vec.append(sg0[ 0]) + vec.append(sg0[-1]) + vec.append(sgX[-1]) + vec.append(sgX[ 0]) + + v_grenier = ti.inverse() * vec + v_room = list(t0.inverse() * vec) + v_room.reverse() + v_room = p3Dv(v_room) + + grenier_wall = openstudio.model.Surface(v_grenier, mdl) + grenier_wall.setSpace(espace) + grenier_wall.setName("%s:%d:%d:%s" % (ids, i, j, idx)) + + room_wall = openstudio.model.Surface(v_room, mdl) + room_wall.setSpace(space) + room_wall.setName("%s:%d:%d:%s" % (ids, i, j, ide)) + + grenier_wall.setAdjacentSurface(room_wall) + room_wall.setAdjacentSurface(grenier_wall) + + # Add individual skylights. Independently of the set layout + # (rows x cols), individual roof inserts may be deeper than + # wider (or vice-versa). Adapt skylight width vs depth + # accordingly. + if round(st["d"], 2) > round(st["w"], 2): + sub["width" ] = st["d"] - f2 + sub["height"] = st["w"] - f2 + else: + sub["width" ] = st["w"] - f2 + sub["height"] = st["d"] - f2 + + sub["id"] = roof.nameString() + addSubs(roof, sub, False, True, True) + + + # Vertically-cast set roof "vtx" onto ceiling. + for st in stz: + cst = cast(ti * st["vtx"], t0 * tile.vertices(), ray) + st["cvtx"] = t0.inverse() * cst + + # Extended ceiling vertices. + vertices = genExtendedVertices(clng, stz, "cvtx") + if not vertices: continue + + # Reset ceiling and adjacent floor vertices. + clng.setVertices(vertices) + fvtx = list(t0 * vertices) + fvtx.reverse() + floor.setVertices(ti.inverse() * p3Dv(fvtx)) + + # Loop through 'direct' roof surfaces of rooms to toplit (no attics or + # plenums). No overlaps, so no relative space coordinate adjustments. + for ide, room in rooms.items(): + for roof in room["roofs"]: + for i, st in enumerate(sets): + if "clng" in st: continue + if "box" not in st: continue + if "cols" not in st: continue + if "rows" not in st: continue + if "d" not in st: continue + if "w" not in st: continue + if "dY" not in st: continue + if "roof" not in st: continue + + if st["roof"] != roof: continue + + w1 = st["w" ] - f2 + d1 = st["d" ] - f2 + dY = st["dY"] + + for j in range(st["rows"]): + sub = {} + sub["type" ] = "Skylight" + sub["count" ] = st["cols"] + sub["width" ] = w1 + sub["height" ] = d1 + sub["id" ] = "%s:%d:%d" % (roof.nameString(), i, j) + sub["sill" ] = dY + j * (2 * dY + d1) + if st["dX"]: sub["r_buffer"] = st["dX"] + if st["dX"]: sub["l_buffer"] = st["dX"] + if frame: sub["frame" ] = frame + + addSubs(roof, sub, False, True, True) + + return rm2 + + def isDaylit(space=None, sidelit=True, toplit=True, baselit=True) -> bool: """Validates whether space has outdoor-facing surfaces with fenestration. @@ -6803,7 +8707,7 @@ def isDaylit(space=None, sidelit=True, toplit=True, baselit=True) -> bool: mth = "osut.isDaylit" cl = openstudio.model.Space walls = [] - roofs = [] + rufs = [] floors = [] if not isinstance(space, openstudio.model.Space): @@ -6825,10 +8729,10 @@ def isDaylit(space=None, sidelit=True, toplit=True, baselit=True) -> bool: return oslg.invalid("baselit", mth, 2, CN.DBG, False) if sidelit: walls = facets(space, "Outdoors", "Wall") - if toplit: roofs = facets(space, "Outdoors", "RoofCeiling") + if toplit: rufs = facets(space, "Outdoors", "RoofCeiling") if baselit: floors = facets(space, "Outdoors", "Floor") - for surface in (walls + roofs + floors): + for surface in (walls + rufs + floors): for sub in surface.subSurfaces(): # All fenestrated subsurface types are considered, as user can set # these explicitly (e.g. skylight in a wall) in OpenStudio. diff --git a/tests/test_osut.py b/tests/test_osut.py index ef3c96a..8c069cd 100644 --- a/tests/test_osut.py +++ b/tests/test_osut.py @@ -817,8 +817,8 @@ def test07_construction_thickness(self): for c in model.getConstructions(): if not c.to_LayeredConstruction(): continue - c = c.to_LayeredConstruction().get() - id = c.nameString() + c = c.to_LayeredConstruction().get() + ide = c.nameString() # OSut 'thickness' method can only process layered constructions # built up with standard opaque layers, which exclude: @@ -829,7 +829,7 @@ def test07_construction_thickness(self): # The method returns '0' in such cases, logging ERROR messages. th = osut.thickness(c) - if "Air Wall" in id or "Double pane" in id: + if "Air Wall" in ide or "Double pane" in ide: self.assertAlmostEqual(th, 0.00, places=2) continue @@ -843,9 +843,9 @@ def test07_construction_thickness(self): for c in model.getConstructions(): if c.to_LayeredConstruction(): continue - c = c.to_LayeredConstruction().get() - id = c.nameString() - if "Air Wall" in id or "Double pane" in id: continue + c = c.to_LayeredConstruction().get() + ide = c.nameString() + if "Air Wall" in ide or "Double pane" in id: continue th = osut.thickness(c) self.assertTrue(th > 0) @@ -1196,7 +1196,7 @@ def test12_insulating_layer(self): m0 = " expecting LayeredConstruction (osut.insulatingLayer)" for lc in model.getLayeredConstructions(): - id = lc.nameString() + ide = lc.nameString() lyr = osut.insulatingLayer(lc) self.assertTrue(isinstance(lyr, dict)) @@ -1220,16 +1220,16 @@ def test12_insulating_layer(self): self.assertTrue(lyr["index"] < lc.numLayers()) - if id == "EXTERIOR-ROOF": + if ide == "EXTERIOR-ROOF": self.assertEqual(lyr["index"], 2) self.assertAlmostEqual(lyr["r"], 5.08, places=2) - elif id == "EXTERIOR-WALL": + elif ide == "EXTERIOR-WALL": self.assertEqual(lyr["index"], 2) self.assertAlmostEqual(lyr["r"], 1.47, places=2) - elif id == "Default interior ceiling": + elif ide == "Default interior ceiling": self.assertEqual(lyr["index"], 0) self.assertAlmostEqual(lyr["r"], 0.12, places=2) - elif id == "INTERIOR-WALL": + elif ide == "INTERIOR-WALL": self.assertEqual(lyr["index"], 1) self.assertAlmostEqual(lyr["r"], 0.24, places=2) else: @@ -1588,10 +1588,10 @@ def test17_minmax_heatcool_setpoints(self): mth1 = "osut.maxHeatScheduledSetpoint" mth2 = "osut.minCoolScheduledSetpoint" - msg1 = "'zone' NoneType? expecting ThermalZone (%s)" % mth1 - msg2 = "'zone' NoneType? expecting ThermalZone (%s)" % mth2 - msg3 = "'zone' str? expecting ThermalZone (%s)" % mth1 - msg4 = "'zone' str? expecting ThermalZone (%s)" % mth2 + m1 = "'zone' NoneType? expecting ThermalZone (%s)" % mth1 + m2 = "'zone' NoneType? expecting ThermalZone (%s)" % mth2 + m3 = "'zone' str? expecting ThermalZone (%s)" % mth1 + m4 = "'zone' str? expecting ThermalZone (%s)" % mth2 for z in model.getThermalZones(): z0 = z.nameString() @@ -1624,7 +1624,7 @@ def test17_minmax_heatcool_setpoints(self): self.assertFalse(res["dual"]) self.assertTrue(o.is_debug()) self.assertEqual(len(o.logs()), 1) - self.assertEqual(o.logs()[0]["message"], msg1) + self.assertEqual(o.logs()[0]["message"], m1) self.assertEqual(o.clean(), DBG) res = osut.minCoolScheduledSetpoint(None) # bad argument @@ -1635,7 +1635,7 @@ def test17_minmax_heatcool_setpoints(self): self.assertFalse(res["dual"]) self.assertTrue(o.is_debug()) self.assertEqual(len(o.logs()), 1) - self.assertEqual(o.logs()[0]["message"], msg2) + self.assertEqual(o.logs()[0]["message"], m2) self.assertEqual(o.clean(), DBG) res = osut.maxHeatScheduledSetpoint("") # bad argument @@ -1646,7 +1646,7 @@ def test17_minmax_heatcool_setpoints(self): self.assertFalse(res["dual"]) self.assertTrue(o.is_debug()) self.assertEqual(len(o.logs()), 1) - self.assertEqual(o.logs()[0]["message"], msg3) + self.assertEqual(o.logs()[0]["message"], m3) self.assertEqual(o.clean(), DBG) res = osut.minCoolScheduledSetpoint("") # bad argument @@ -1657,7 +1657,7 @@ def test17_minmax_heatcool_setpoints(self): self.assertFalse(res["dual"]) self.assertTrue(o.is_debug()) self.assertEqual(len(o.logs()), 1) - self.assertEqual(o.logs()[0]["message"], msg4) + self.assertEqual(o.logs()[0]["message"], m4) self.assertEqual(o.clean(), DBG) # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # @@ -1742,10 +1742,11 @@ def test18_hvac_airloops(self): self.assertEqual(o.status(), 0) self.assertEqual(o.reset(DBG), DBG) self.assertEqual(o.level(), DBG) + translator = openstudio.osversion.VersionTranslator() + + m = "'model' str? expecting Model (osut.hasAirLoopsHVAC)" - msg = "'model' str? expecting Model (osut.hasAirLoopsHVAC)" version = int("".join(openstudio.openStudioVersion().split("."))) - translator = openstudio.osversion.VersionTranslator() # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # path = openstudio.path("./tests/files/osms/out/seb2.osm") @@ -1759,7 +1760,7 @@ def test18_hvac_airloops(self): self.assertEqual(osut.hasAirLoopsHVAC(""), False) self.assertTrue(o.is_debug()) self.assertEqual(len(o.logs()), 1) - self.assertEqual(o.logs()[0]["message"], msg) + self.assertEqual(o.logs()[0]["message"], m) self.assertEqual(o.clean(), DBG) del model @@ -1776,7 +1777,7 @@ def test18_hvac_airloops(self): self.assertEqual(osut.hasAirLoopsHVAC(""), False) self.assertTrue(o.is_debug()) self.assertEqual(len(o.logs()), 1) - self.assertEqual(o.logs()[0]["message"], msg) + self.assertEqual(o.logs()[0]["message"], m) self.assertEqual(o.clean(), DBG) del model @@ -1796,7 +1797,7 @@ def test19_vestibules(self): # Tag "Entry way 1" in SEB as a vestibule. tag = "vestibule" - msg = "Invalid 'vestibule' arg #1 (osut.areVestibules)" + m = "Invalid 'vestibule' arg #1 (osut.areVestibules)" entry = model.getSpaceByName("Entry way 1") self.assertTrue(entry) entry = entry.get() @@ -1830,7 +1831,7 @@ def test19_vestibules(self): self.assertFalse(osut.areVestibules(entry)) self.assertTrue(o.is_error()) self.assertEqual(len(o.logs()), 1) - self.assertEqual(o.logs()[0]["message"], msg) + self.assertEqual(o.logs()[0]["message"], m) self.assertEqual(o.clean(), DBG) self.assertTrue(entry.additionalProperties().resetFeature(tag)) @@ -2044,7 +2045,7 @@ def test20_setpoints_plenums_attics(self): # Tag attic instead as an SEMIHEATED space. First, test an invalid entry. key = "space_conditioning_category" val = "Demiheated" - msg = "Invalid '%s:%s' (osut.setpoints)" % (key, val) + m = "Invalid '%s:%s' (osut.setpoints)" % (key, val) self.assertTrue(attic.additionalProperties().setFeature(key, val)) stps = osut.setpoints(attic) self.assertFalse(osut.arePlenums(attic)) @@ -2059,7 +2060,7 @@ def test20_setpoints_plenums_attics(self): # 3x same error, as arePlenums/isUnconditioned call setpoints(attic). self.assertEqual(len(o.logs()), 3) - for l in o.logs(): self.assertEqual(l["message"], msg) + for l in o.logs(): self.assertEqual(l["message"], m) # Now test a valid entry. self.assertTrue(attic.additionalProperties().resetFeature(key)) @@ -4442,8 +4443,8 @@ def test30_wwr_insertions(self): model.save("./tests/files/osms/out/seb_ext3.osm", True) # Fetch a (flat) plenum roof surface, and add a single skylight. - id = "Level 0 Open area 1 ceiling Plenum RoofCeiling" - ruf1 = model.getSurfaceByName(id) + ide = "Level 0 Open area 1 ceiling Plenum RoofCeiling" + ruf1 = model.getSurfaceByName(ide) self.assertTrue(ruf1) ruf1 = ruf1.get() @@ -4488,7 +4489,7 @@ def test31_convexity(self): attic = None for space in model.getSpaces(): - id = space.nameString() + ide = space.nameString() if version >= 350: self.assertTrue(space.isVolumeAutocalculated) @@ -4496,7 +4497,7 @@ def test31_convexity(self): self.assertTrue(space.isFloorAreaDefaulted) self.assertTrue(space.isFloorAreaAutocalculated) - if id == "Attic": + if ide == "Attic": self.assertFalse(space.partofTotalFloorArea()) attic = space continue @@ -4697,9 +4698,9 @@ def test32_outdoor_roofs(self): self.assertEqual(len(roofs), len(spaces)) - for id, surface in spaces.items(): - self.assertTrue(id in roofs.keys()) - self.assertEqual(roofs[id], surface) + for ide, surface in spaces.items(): + self.assertTrue(ide in roofs) + self.assertEqual(roofs[ide], surface) del model @@ -4743,7 +4744,7 @@ def test32_outdoor_roofs(self): self.assertEqual(o.status(), 0) for occ in occupied: - self.assertTrue(occ in roofs.keys()) + self.assertTrue(occ in roofs) self.assertTrue("plenum" in roofs[occ].lower()) del model @@ -4931,7 +4932,7 @@ def test34_generated_skylight_wells(self): self.assertAlmostEqual(osut.rsi(io_wall, 0.150), 0.31, places=2) for space in model.getSpaces(): - id = space.nameString() + ide = space.nameString() if not space.partofTotalFloorArea(): attic.append(space) @@ -4941,9 +4942,9 @@ def test34_generated_skylight_wells(self): toplit = osut.isDaylit(space, False) self.assertFalse(toplit) - if "Perimeter" in id: + if "Perimeter" in ide: self.assertTrue(sidelit) - elif "Core" in id: + elif "Core" in ide: self.assertFalse(sidelit) core.append(space) @@ -4962,53 +4963,38 @@ def test34_generated_skylight_wells(self): self.assertAlmostEqual(total1, total2, places=2) self.assertAlmostEqual(total2, 598.76, places=2) - # attic = model.getSpaceByName("Attic") - # pZN4 = model.getSpaceByName("Perimeter_ZN_4") - # self.assertTrue(attic) - # self.assertTrue(pZN4) - # attic = attic.get() - # pZN4 = pZN4.get() - # pZN4_ceiling = model.getSurfaceByName("Perimeter_ZN_4_ceiling") - # aSouth_roof = model.getSurfaceByName("Attic_roof_south") - # self.assertTrue(pZN4_ceiling) - # self.assertTrue(aSouth_roof) - # pZN4_ceiling = pZN4_ceiling.get() - # aSouth_roof = aSouth_roof.get() - # - # up = openstudio.Point3d(0,0,1) - openstudio.Point3d(0,0,0) - # t0 = osut.transforms(attic) - # ti = osut.transforms(pZN4) - # - # roof0 = t0["t"] * aSouth_roof.vertices() - # ceili = ti["t"] * pZN4_ceiling.vertices() - # - # print(" --- ROOF0 --- --- --- --- --- ") - # for pt in roof0: print(pt) - # print(" --- CEILI --- --- --- --- --- ") - # for pt in ceili: print(pt) - # - # cst = osut.cast(ceili, roof0, up) - # print(" --- CST --- --- --- --- --- ") - # for pt in cst: print(pt) - # - # olap = osut.overlap(cst, roof0, False) - # print(olap.__class__.__name__) - # print(len(olap)) - # for pt in olap: print(pt) - # self.assertTrue(olap) - # print(" --- OLAP --- --- --- --- --- ") - # for pt in olap: print(pt) - # - # m2 = openstudio.getArea(olap) - # self.assertTrue(m2) - # m2 = m2.get() - # print(" --- m2 --- --- --- --- --- ") - # print(m2) - # "GROSS ROOF AREA" (GRA), as per 90.1/NECB - excludes overhangs (60m2) gra1 = osut.grossRoofArea(model.getSpaces()) self.assertAlmostEqual(gra1, 538.86, places=2) + # Unless model geometry is too granular (e.g. finely tessellated), the + # method 'addSkyLights' generates skylight/wells achieving user-required + # skylight-to-roof ratios (SRR%). The distinction between TOTAL vs GRA + # is obviously key for SRR% calculations (i.e. denominators). + + # 2x test CASES: + # 1. UNCONDITIONED (attic, as is) + # 2. INDIRECTLY-CONDITIONED (e.g. plenum) + # + # For testing purposes, only the core zone here is targeted for skylight + # wells. Context: NECBs and 90.1 require separate SRR% calculations for + # differently conditioned spaces (SEMI-CONDITIONED vs CONDITIONED). + # Consider this as practice - see 'addSkyLights' doc. + + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + # CASE 1: + # Retrieve core GRA. As with overhangs, only the attic roof 'sections' + # directly-above the core are retained for SRR% calculations. Here, the + # GRA is substantially lower (than previously-calculated gra1). For now, + # calculated GRA is only valid BEFORE adding skylight wells. + gra_attic = osut.grossRoofArea(core) + self.assertAlmostEqual(gra_attic, 157.77, places=2) + + # The method returns the GRA, calculated BEFORE adding skylights/wells. + # rm2 = osut.addSkyLights(core, dict(srr=srr)) + # print(o.logs()) + # self.assertAlmostEqual(rm2, gra_attic, places=2) + self.assertEqual(o.status(), 0) del model @@ -5190,8 +5176,8 @@ def test36_slab_generation(self): slab = osut.genSlab(plates, z0) self.assertTrue(o.is_error()) - msg = o.logs()[0]["message"] - self.assertEqual(msg, "Invalid 'plate # 4 (index 3)' (osut.genSlab)") + m = o.logs()[0]["message"] + self.assertEqual(m, "Invalid 'plate # 4 (index 3)' (osut.genSlab)") self.assertEqual(o.clean(), DBG) self.assertTrue(isinstance(slab, openstudio.Point3dVector)) self.assertFalse(slab) From bfc94408fd07bf3b360c2684ebedd22fc735653d Mon Sep 17 00:00:00 2001 From: brgix Date: Fri, 1 Aug 2025 17:12:35 -0400 Subject: [PATCH 42/49] Purges 'set' variables (conflict with built-in) --- src/osut/osut.py | 494 ++++++++++++++++++++++----------------------- tests/test_osut.py | 10 +- 2 files changed, 252 insertions(+), 252 deletions(-) diff --git a/src/osut/osut.py b/src/osut/osut.py index 8ac03ca..5d0ca6b 100644 --- a/src/osut/osut.py +++ b/src/osut/osut.py @@ -825,11 +825,11 @@ def genMass(sps=None, ratio=2.0) -> bool: return True -def holdsConstruction(set=None, base=None, gr=False, ex=False, type=""): +def holdsConstruction(cset=None, base=None, gr=False, ex=False, type=""): """Validates whether a default construction set holds a base construction. Args: - set (openstudio.model.DefaultConstructionSet): + cset (openstudio.model.DefaultConstructionSet): A default construction set. base (openstudio.model.ConstructionBase): A construction base. @@ -854,8 +854,8 @@ def holdsConstruction(set=None, base=None, gr=False, ex=False, type=""): t2 = [t.lower() for t in t2] c = None - if not isinstance(set, cl1): - return oslg.mismatch("set", set, cl1, mth, CN.DBG, False) + if not isinstance(cset, cl1): + return oslg.mismatch("set", cset, cl1, mth, CN.DBG, False) if not isinstance(base, cl2): return oslg.mismatch("base", base, cl2, mth, CN.DBG, False) if not isinstance(gr, bool): @@ -872,23 +872,23 @@ def holdsConstruction(set=None, base=None, gr=False, ex=False, type=""): if type in t1: if gr: - if set.defaultGroundContactSurfaceConstructions(): - c = set.defaultGroundContactSurfaceConstructions().get() + if cset.defaultGroundContactSurfaceConstructions(): + c = cset.defaultGroundContactSurfaceConstructions().get() elif ex: - if set.defaultExteriorSurfaceConstructions(): - c = set.defaultExteriorSurfaceConstructions().get() + if cset.defaultExteriorSurfaceConstructions(): + c = cset.defaultExteriorSurfaceConstructions().get() else: - if set.defaultInteriorSurfaceConstructions(): - c = set.defaultInteriorSurfaceConstructions().get() + if cset.defaultInteriorSurfaceConstructions(): + c = cset.defaultInteriorSurfaceConstructions().get() elif type in t2: if gr: return False if ex: - if set.defaultExteriorSubSurfaceConstructions(): - c = set.defaultExteriorSubSurfaceConstructions().get() + if cset.defaultExteriorSubSurfaceConstructions(): + c = cset.defaultExteriorSubSurfaceConstructions().get() else: - if set.defaultInteriorSubSurfaceConstructions(): - c = set.defaultInteriorSubSurfaceConstructions().get() + if cset.defaultInteriorSubSurfaceConstructions(): + c = cset.defaultInteriorSubSurfaceConstructions().get() else: return oslg.invalid("surface type", mth, 5, CN.DBG, False) @@ -965,36 +965,36 @@ def defaultConstructionSet(s=None): exterior = True if bnd == "outdoors" else False if space.defaultConstructionSet(): - set = space.defaultConstructionSet().get() + cset = space.defaultConstructionSet().get() - if holdsConstruction(set, base, ground, exterior, type): return set + if holdsConstruction(cset, base, ground, exterior, type): return cset if space.spaceType(): spacetype = space.spaceType().get() if spacetype.defaultConstructionSet(): - set = spacetype.defaultConstructionSet().get() + cset = spacetype.defaultConstructionSet().get() - if holdsConstruction(set, base, ground, exterior, type): - return set + if holdsConstruction(cset, base, ground, exterior, type): + return cset if space.buildingStory(): story = space.buildingStory().get() if story.defaultConstructionSet(): - set = story.defaultConstructionSet().get() + cset = story.defaultConstructionSet().get() - if holdsConstruction(set, base, ground, exterior, type): - return set + if holdsConstruction(cset, base, ground, exterior, type): + return cset building = mdl.getBuilding() if building.defaultConstructionSet(): - set = building.defaultConstructionSet().get() + cset = building.defaultConstructionSet().get() - if holdsConstruction(set, base, ground, exterior, type): - return set + if holdsConstruction(cset, base, ground, exterior, type): + return cset return None @@ -1228,13 +1228,13 @@ def insulatingLayer(lc=None) -> dict: return res -def areSpandrels(set=None) -> bool: +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 layers, by looking up AdditionalProperties or identifiers. Args: - set (list): + surfaces (list): One or more openstudio.model.Surface instances. Returns: @@ -1244,15 +1244,15 @@ def areSpandrels(set=None) -> bool: mth = "osut.areSpandrels" cl = openstudio.model.Surface - if isinstance(set, cl): - set = [set] + if isinstance(surfaces, cl): + surfaces = [surfaces] else: try: - set = list(set) + surfaces = list(surfaces) except: - return oslg.mismatch("set", set, list, mth, CN.DBG, False) + return oslg.mismatch("surfaces", surfaces, list, mth, CN.DBG, False) - for i, s in enumerate(set): + for i, s in enumerate(surfaces): if not isinstance(s, cl): return oslg.mismatch("surface %d" % i, s, cl, mth, CN.DBG, False) @@ -2009,11 +2009,11 @@ def hasCoolingTemperatureSetpoints(model=None): return False -def areVestibules(set=None): +def areVestibules(spaces=None): """Validates whether one or more spaces can be considered vestibules(s). Args: - set (list): + spaces (list): One or more openstudio.model.Space instances. Returns: @@ -2055,12 +2055,12 @@ def areVestibules(set=None): mth = "osut.areVestibules" cl = openstudio.model.Space - if isinstance(set, cl): - set = [set] - elif not isinstance(set, list): - return oslg.mismatch("set", set, list, mth, CN.DBG, False) + if isinstance(spaces, cl): + spaces = [spaces] + elif not isinstance(spaces, list): + return oslg.mismatch("spaces", spaces, list, mth, CN.DBG, False) - for space in set: + for space in spaces: if not isinstance(space, cl): return oslg.mismatch("space", space, cl, mth, CN.DBG, False) @@ -2088,12 +2088,12 @@ def areVestibules(set=None): return True -def arePlenums(set=None): +def arePlenums(spaces=None): """Validates whether one or more spaces can be considered indirectly-conditioned plenum(s). Args: - set (list):space (openstudio.model.Space): + spaces (list): One or more openstudio.model.Space instances. Returns: @@ -2164,12 +2164,12 @@ def arePlenums(set=None): mth = "osut.arePlenums" cl = openstudio.model.Space - if isinstance(set, cl): - set = [set] - elif not isinstance(set, list): - return oslg.mismatch("set", set, list, mth, CN.DBG, False) + if isinstance(spaces, cl): + spaces = [spaces] + elif not isinstance(spaces, list): + return oslg.mismatch("spaces", spaces, list, mth, CN.DBG, False) - for space in set: + for space in spaces: if not isinstance(space, cl): return oslg.mismatch("space", space, cl, mth, CN.DBG, False) @@ -5104,11 +5104,11 @@ def realignedFace(pts=None, force=False) -> dict: xy = openstudio.Point3d(origin.x() + dX, origin.y() + dY, 0) o2 = xy - origin t = openstudio.createTranslation(o2) - set = p3Dv(t.inverse() * pts) + st = p3Dv(t.inverse() * pts) box = p3Dv(t.inverse() * box) - bbox = outline([set]) + bbox = outline([st]) - out["set" ] = blc(set) + out["set" ] = blc(st) out["box" ] = blc(box) out["bbox"] = blc(bbox) out["t" ] = t @@ -5183,7 +5183,7 @@ def alignedHeight(pts=None, force=False) -> float: return max(ys) - min(ys) -def spacHeight(space=None) -> float: +def spaceHeight(space=None) -> float: """Fetch a space's full height. Args: @@ -5282,7 +5282,7 @@ def spaceWidth(space=None) -> float: return height(box) -def genAnchors(s=None, set=[], tag="box") -> int: +def genAnchors(s=None, sset=[], tag="box") -> int: """Identifies 'leader line anchors', i.e. specific 3D points of a (larger) set (e.g. delineating a larger, parent polygon), each anchor linking the BLC corner of one or more (smaller) subsets (free-floating within the @@ -5305,8 +5305,8 @@ def genAnchors(s=None, set=[], tag="box") -> int: Args: s (openstudio.Point3dVector): A (larger) parent set of points. - set (list): - A collection of (smaller) sequenced points, to 'anchor'. + sset (list): + Subsets of (smaller) sequenced points, to 'anchor'. tag (str): Selected subset vertices to target. @@ -5324,16 +5324,16 @@ def genAnchors(s=None, set=[], tag="box") -> int: return oslg.invalid("%s polygon" % ide, mth, 1, CN.DBG, n) try: - set = list(set) + sset = list(sset) except: - return oslg.mismatch("set", set, list, mth, CN.DBG, n) + return oslg.mismatch("subset", sset, list, mth, CN.DBG, n) origin = openstudio.Point3d(0,0,0) zenith = openstudio.Point3d(0,0,1) ray = zenith - origin # Validate individual subsets. Purge surface-specific leader line anchors. - for i, st in enumerate(set): + for i, st in enumerate(sset): str1 = ide + "subset %d" % (i+1) str2 = str1 + " %s" % str(tag) @@ -5367,7 +5367,7 @@ def genAnchors(s=None, set=[], tag="box") -> int: else: st["ld"] = {} - for i, st in enumerate(set): + for i, st in enumerate(sset): # When a subset already holds a leader line anchor (from an initial call # to 'genAnchors'), it inherits key "out" - a dictionary holding (among # others) a 'realigned' set of points (by default a 'realigned' "box"). @@ -5404,7 +5404,7 @@ def genAnchors(s=None, set=[], tag="box") -> int: st[tag ] = tpts # Identify candidate leader line anchors for each subset. - for i, st in enumerate(set): + for i, st in enumerate(sset): candidates = [] tpts = st[tag] @@ -5419,7 +5419,7 @@ def genAnchors(s=None, set=[], tag="box") -> int: if doesLineIntersect(sg, ld): nb += 1 # Intersections between candidate leader line vs other subsets? - for other in set: + for other in sset: if nb != 0: break if st == other: continue @@ -5429,7 +5429,7 @@ def genAnchors(s=None, set=[], tag="box") -> int: if doesLineIntersect(ld, sg): nb += 1 # ... and previous leader lines (first come, first serve basis). - for other in set: + for other in sset: if nb != 0: break if st == other: continue if "ld" not in other: continue @@ -5465,7 +5465,7 @@ def genAnchors(s=None, set=[], tag="box") -> int: st["ld"][ids] = p0 n += 1 else: - str = ide + ("set #%d" % (i+1)) + str = ide + ("subset #%d" % (i+1)) m = "%s: unable to anchor %s leader line (%s)" % (str, tag, mth) oslg.log(WRN, m) st["void"] = True @@ -5473,7 +5473,7 @@ def genAnchors(s=None, set=[], tag="box") -> int: return n -def genExtendedVertices(s=None, set=[], tag="vtx") -> openstudio.Point3dVector: +def genExtendedVertices(s=None, sset=[], tag="vtx") -> openstudio.Point3dVector: """Extends (larger) polygon vertices to circumscribe one or more (smaller) subsets of vertices, based on previously-generated 'leader line' anchors. The solution minimally validates individual subsets (e.g. no @@ -5487,8 +5487,8 @@ def genExtendedVertices(s=None, set=[], tag="vtx") -> openstudio.Point3dVector: Args: s (openstudio.Point3dVector): A (larger) parent set of points. - set (list): - A collection of (smaller) sequenced points. + sset (list): + Subsets of (smaller) sequenced points. tag (str): Selected subset vertices to target. @@ -5508,12 +5508,12 @@ def genExtendedVertices(s=None, set=[], tag="vtx") -> openstudio.Point3dVector: if not pts: return oslg.invalid("%s polygon" % ide, mth, 1, CN.DBG, a) try: - set = list(set) + sset = list(sset) except: - return oslg.mismatch("set", set, list, mth, CN.DBG, a) + return oslg.mismatch("subset", sset, list, mth, CN.DBG, a) - # Validate individual sets. - for i, st in enumerate(set): + # Validate individual subsets. + for i, st in enumerate(sset): str1 = ide + "subset %d" % (i+1) str2 = str1 + " %s" % str(tag) @@ -5551,8 +5551,8 @@ def genExtendedVertices(s=None, set=[], tag="vtx") -> openstudio.Point3dVector: for pt in pts: v.append(pt) - # Loop through each valid set; concatenate circumscribing vertices. - for st in set: + # Loop through each valid subset; concatenate circumscribing vertices. + for st in sset: if "void" in st and st["void"]: continue if not areSame(st["ld"][ids], pt): continue if tag not in st: continue @@ -5563,7 +5563,7 @@ def genExtendedVertices(s=None, set=[], tag="vtx") -> openstudio.Point3dVector: return p3Dv(v) -def genInserts(s=None, set=[]) -> openstudio.Point3dVector: +def genInserts(s=None, sset=[]) -> openstudio.Point3dVector: """Generates (1D or 2D) arrays of (smaller) rectangular collection of points (e.g. arrays of polygon inserts) from subset parameters, within a (larger) set (e.g. parent polygon). If successful, each subset inherits @@ -5575,8 +5575,8 @@ def genInserts(s=None, set=[]) -> openstudio.Point3dVector: Args: s (openstudio.Point3dVector): A (larger) parent set of points. - set (list): - A collection of (smaller) sequenced points (dictionnaries). Each + sset (list): + Subsets of (smaller) sequenced points (dictionnaries). Each collection shall/may hold the following key:value pairs. - "box" (openstudio.Point3dVector): bounding box of each subset - "ld" (dict): a collection of leader line anchors @@ -5599,15 +5599,15 @@ def genInserts(s=None, set=[]) -> openstudio.Point3dVector: if not pts: return a try: - set = list(set) + sset = list(sset) except: - return oslg.mismatch("set", set, list, mth, CN.DBG, a) + return oslg.mismatch("subset", sset, list, mth, CN.DBG, a) gap = 0.1 gap4 = 0.4 # minimum insert width/depth - # Validate/reset individual set collections. - for i, st in enumerate(set): + # Validate/reset individual subset collections. + for i, st in enumerate(sset): str1 = ide + "subset #%d" % (i+1) if "void" in st and st["void"]: continue @@ -5630,7 +5630,7 @@ def genInserts(s=None, set=[]) -> openstudio.Point3dVector: if not isinstance(ld[id(s)], cl): return oslg.mismatch(str2, ld[id(s)], cl, mth, CN.DBG, a) - # Ensure each set bounding box is safely within larger polygon + # Ensure each subset bounding box is safely within larger polygon # boundaries. # @todo: In line with related addSkylights' @todo, expand solution to # safely handle 'side' cutouts (i.e. no need for leader lines). @@ -5705,17 +5705,17 @@ def genInserts(s=None, set=[]) -> openstudio.Point3dVector: else: st["dY"] = None - # Flag conflicts between set bounding boxes. @todo: ease up for ridges. - for i, st in enumerate(set): + # Flag conflicts between subset bounding boxes. @todo: ease up for ridges. + for i, st in enumerate(sset): bx = st["box"] if "void" in st and st["void"]: continue - for j, other in enumerate(set): + for j, other in enumerate(sset): if i == j: continue bx2 = other["box"] if overlapping(bx, bx2): - str4 = ide + "set boxes #%d:#%d" % (i+1, j+1) + str4 = ide + "subset boxes #%d:#%d" % (i+1, j+1) return oslg.invalid("%s (overlapping)" % str4, mth, 0, CN.DBG, a) @@ -5724,7 +5724,7 @@ def genInserts(s=None, set=[]) -> openstudio.Point3dVector: # Loop through each 'valid' subset (i.e. linking a valid leader line # anchor), generate subset vertex array based on user-provided specs. - for i, st in enumerate(set): + for i, st in enumerate(sset): str5 = ide + "subset #%d" % (i+1) if "void" in st and st["void"]: continue @@ -5827,7 +5827,7 @@ def genInserts(s=None, set=[]) -> openstudio.Point3dVector: st["vtx"] = p3Dv(t * (o["r"] * (o["t"] * vtx))) # Extended vertex sequence of the larger polygon. - genExtendedVertices(s, set) + genExtendedVertices(s, sset) def facets(spaces=[], boundary="all", type="all", sides=[]) -> list: @@ -6058,7 +6058,7 @@ def roofs(spaces = []) -> list: Args: spaces (list): - Set of openstudio.model.Space instances. + A collection of openstudio.model.Space instances. Returns: list of openstudio.model.Surface instances: roofs (may be empty). @@ -6811,7 +6811,7 @@ def grossRoofArea(spaces=[]) -> float: Args: spaces (list): - Set of openstudio.model.Space instances. + A collection of openstudio.model.Space instances. Returns: float: Gross roof surface area. @@ -6938,10 +6938,10 @@ def getHorizontalRidges(rufs=[]) -> list: Args: rufs (list): - A set of 'roof' openstudio.model.Surface instances. + A Collection of 'roof' openstudio.model.Surface instances. Returns: - list: A set of horizontal roof ridge dictionaries: + list: A collection of horizontal roof ridge dictionaries: - "edge" (openstudio.Point3dVector): both edge endpoints - "length" (float): individual horizontal roof ridge length - "roofs" (list): 2x linked roof surfaces, on either side of the edge @@ -7023,7 +7023,7 @@ def toToplit(spaces=[], opts={}) -> list: Args: spaces (list): - Set of openstudio.model.Space instances. + A collection of openstudio.model.Space instances. opts (dict): Requested skylight attributes (similar to 'addSkylights'). - "size" (float): Template skylight width/depth (1.22m, min 0.4m) @@ -7072,7 +7072,7 @@ def toToplit(spaces=[], opts={}) -> list: spaces = [s for s in spaces if not isUnconditioned(s)] spaces = [s for s in spaces if not areVestibules(s)] spaces = [s for s in spaces if roofs(s)] - spaces = [s for s in spaces if s.floorArea() < 4 * w2] + spaces = [s for s in spaces if s.floorArea() >= 4 * w2] spaces = sorted(spaces, key=lambda s: s.floorArea(), reverse=True) if not spaces: return oslg.empty("spaces", mth, CN.WRN, []) @@ -7113,10 +7113,10 @@ def toToplit(spaces=[], opts={}) -> list: # Gather roof surfaces - possibly those of attics or plenums above. for s in spaces: ide = s.nameString() - m2 = s.floorArea() + m2 = s.floorArea() for rf in roofs(s): - if ide not in espaces: espaces[ide] = dict(m2=m2, roofs=[]) + if ide not in espaces: espaces[ide] = dict(space=s, m2=m2, roofs=[]) if rf not in espaces[ide]["roofs"]: espaces[ide]["roofs"].append(rf) # Priortize larger spaces. @@ -7135,7 +7135,7 @@ def toToplit(spaces=[], opts={}) -> list: rfs = sorted(rfs, key=lambda ruf: ruf.grossArea(), reverse=True) toits.append(rfs[0]) - rooms.append(s) + rooms.append(s["space"]) if not rooms: oslg.log(CN.INF, "No ideal toplit candidates (%s)" % mth) @@ -7346,7 +7346,6 @@ def addSkyLights(spaces=[], opts=dict) -> float: return oslg.mismatch("area", opts["area"], float, mth, CN.DBG, 0) if area < 0: oslg.log(CN.WRN, "Area reset to 0.0 m2 (%s)" % mth) - elif "srr" in opts: try: srr = float(opts["srr"]) @@ -7438,6 +7437,7 @@ def addSkyLights(spaces=[], opts=dict) -> float: # By default, seek ideal candidate spaces/roofs. Bail out if unsuccessful. if opts["ration"] is True: spaces = toToplit(spaces, opts) + print(spaces.__class__.__name__) if not spaces: return rm2 # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # @@ -7564,7 +7564,7 @@ def addSkyLights(spaces=[], opts=dict) -> float: # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # # Break down spaces (and connected spaces) into groups. - sets = [] # collection of skylight arrays to deploy + ssets = [] # subset of skylight arrays to deploy rooms = {} # occupied CONDITIONED spaces to toplight plenums = {} # unoccupied (INDIRECTLY-) CONDITIONED spaces above rooms attics = {} # unoccupied UNCONDITIONED spaces above rooms @@ -7601,7 +7601,7 @@ def addSkyLights(spaces=[], opts=dict) -> float: rooms[ide]["sidelit"] = isDaylit(space, True, False, False) # Fetch and process room-specific outdoor-facing roof surfaces. - # e.g. the most basic 'set' to track: + # e.g. the most basic 'subset' to track: # - no skylight wells (i.e. no leader lines) # - 1x skylight array per roof surface # - no need to consider site transformation @@ -7623,26 +7623,26 @@ def addSkyLights(spaces=[], opts=dict) -> float: if width < wl * 3: continue if depth < wl: continue - # A set is 'tight' if the area of its bounded box is significantly - # smaller than that of its roof. A set is 'thin' if the depth of - # its bounded box is (too) narrow. If either is True, some geometry - # rules may be relaxed to maximize allocated skylight area. Neither - # apply to cases with skylight wells. + # A subset is 'tight' if the area of its bounded box is + # significantly smaller than that of its roof. A subset is 'thin' if + # the depth of its bounded box is (too) narrow. If either is True, + # some geometry rules may be relaxed to maximize allocated skylight + # area. Neither apply to cases with skylight wells. tight = True if bm2 < roof.grossArea() / 2 else False thin = True if round(depth, 2) < round(1.5 * wl, 2) else False - set = {} - set["box" ] = box - set["bm2" ] = bm2 - set["tight" ] = tight - set["thin" ] = thin - set["roof" ] = roof - set["space" ] = space - set["m" ] = space.multiplier() - set["sidelit"] = rooms[space]["sidelit"] - set["t0" ] = rooms[space]["t0"] - set["t" ] = openstudio.Transformation.alignFace(vtx) - sets.append(set) + sset = {} + sset["box" ] = box + sset["bm2" ] = bm2 + sset["tight" ] = tight + sset["thin" ] = thin + sset["roof" ] = roof + sset["space" ] = space + sset["m" ] = space.multiplier() + sset["sidelit"] = rooms[space]["sidelit"] + sset["t0" ] = rooms[space]["t0"] + sset["t" ] = openstudio.Transformation.alignFace(vtx) + ssets.append(sset) # Process outdoor-facing roof surfaces of plenums and attics above. for ide, room in rooms.items(): @@ -7754,29 +7754,29 @@ def addSkyLights(spaces=[], opts=dict) -> float: ceilings[idee]["roofs"].append(ruf) - # Skylight set key:values are more detailed with suspended + # Skylight subset key:values are more detailed with suspended # ceilings. The overlap ("olap") remains in 'transformed' site # coordinates (with regards to the roof). The "box" polygon # reverts to attic/plenum space coordinates, while the "cbox" # polygon is reset with regards to the occupied space # coordinates. - set = {} - set["olap" ] = olap - set["box" ] = box - set["cbox" ] = cbox - set["om2" ] = om2 - set["bm2" ] = bm2 - set["cm2" ] = cm2 - set["tight" ] = False - set["thin" ] = False - set["roof" ] = ruf - set["space" ] = space - set["m" ] = space.multiplier() - set["clng" ] = tile - set["t0" ] = t0 - set["ti" ] = ti - set["t" ] = openstudio.Transformation.alignFace(vtx) - set["sidelit"] = room["sidelit"] + sset = {} + sset["olap" ] = olap + sset["box" ] = box + sset["cbox" ] = cbox + sset["om2" ] = om2 + sset["bm2" ] = bm2 + sset["cm2" ] = cm2 + sset["tight" ] = False + sset["thin" ] = False + sset["roof" ] = ruf + sset["space" ] = space + sset["m" ] = space.multiplier() + sset["clng" ] = tile + sset["t0" ] = t0 + sset["ti" ] = ti + sset["t" ] = openstudio.Transformation.alignFace(vtx) + sset["sidelit"] = room["sidelit"] if isUnconditioned(espace): # e.g. attic if idx not in attics: # idx = espace.nameString() @@ -7790,7 +7790,7 @@ def addSkyLights(spaces=[], opts=dict) -> float: attics[idx]["bm2" ] += bm2 attics[idx]["roofs"].append(ruf) - set["attic"] = espace + sset["attic"] = espace ceilings[idee]["attic"] = espace # adjacent attic (floor) else: # e.g. plenum @@ -7805,11 +7805,11 @@ def addSkyLights(spaces=[], opts=dict) -> float: plenums[idx]["bm2" ] += bm2 plenums[idx]["roofs"].append(ruf) - set["plenum"] = espace + sset["plenum"] = espace ceilings[idee]["plenum"] = espace # adjacent plenum (floor) - sets.append(set) + ssets.append(sset) break # only 1x unique ruf/ceiling pair. # Ensure uniqueness of plenum roofs. @@ -7822,14 +7822,14 @@ def addSkyLights(spaces=[], opts=dict) -> float: plenum["ridges"] = horizontalRidges(plenum["roofs"]) # @todo # Regardless of the selected skylight arrangement pattern, the solution only - # considers attic/plenum sets that can be successfully linked to leader line - # anchors, for both roof and ceiling surfaces. First, attic/plenum roofs. + # considers attic/plenum subsets that can be successfully linked to leader + # line anchors, for both roof and ceiling surfaces. First, attic/plenum roofs. for greniers in [attics, plenums]: k = "attic" if greniers == attics else "plenum" for grenier in greniers.values(): for roof in grenier["roofs"]: - sts = sets + sts = ssets sts = [st for st in sts if k in st] sts = [st for st in sts if "space" in st] sts = [st for st in sts if "box" in st] @@ -7844,7 +7844,7 @@ def addSkyLights(spaces=[], opts=dict) -> float: genAnchors(roof, sts, "box") # Delete voided sets. - sets = [set for set in sets if "void" not in set] + ssets = [sset for sset in ssets if "void" not in sset] # Repeat leader line loop for ceilings. for ceiling in ceilings.values(): @@ -7861,7 +7861,7 @@ def addSkyLights(spaces=[], opts=dict) -> float: stz = [] for roof in ceiling["roofs"]: - sts = sets + sts = ssets sts = [st for st in sts if k in st] sts = [st for st in sts if "cbox" in st] @@ -7884,22 +7884,22 @@ def addSkyLights(spaces=[], opts=dict) -> float: genAnchors(tile, stz, "cbox") # Delete voided sets. - sets = [set for set in sets if "void" not in set] - if not sets: return oslg.empty("sets", mth, CN.WRN, rm2) + ssets = [sset for sset in ssets if "void" not in sset] + if not ssets: return oslg.empty("subsets", mth, CN.WRN, rm2) - # Sort sets, from largest to smallest bounded box area. - sets = sorted(sets, key=lambda st: st["bm2"] * st["m"], reverse=True) + # Sort subsets, from largest to smallest bounded box area. + ssets = sorted(ssets, key=lambda st: st["bm2"] * st["m"], reverse=True) # Any sidelit and/or sloped roofs being targeted? # @todo: enable double-ridged, sloped roofs have double-sloped # skylights/wells (patterns "strip"/"strips"). - sidelit = any(set["sidelit"] for set in sets) - sloped = any(set["sloped" ] for set in sets) + sidelit = any(sset["sidelit"] for sset in ssets) + sloped = any(sset["sloped" ] for sset in ssets) # Average sandbox area + revised 'working' SRR%. - sbm2 = sum(set.get("bm2", 0) for set in sets) - avm2 = sbm2 / len(sets) - srr2 = sm2 / len(sets) / avm2 + sbm2 = sum(sset.get("bm2", 0) for sset in ssets) + avm2 = sbm2 / len(ssets) + srr2 = sm2 / len(ssets) / avm2 # Precalculate skylight rows + cols, for each selected pattern. In the case # of 'cols x rows' arrays of skylights, the method initially overshoots @@ -7908,40 +7908,40 @@ def addSkyLights(spaces=[], opts=dict) -> float: # aceee.org/files/proceedings/2004/data/papers/SS04_Panel3_Paper18.pdf # # Skylight areas are subsequently contracted to strictly meet the target. - for i, set in enumerate(sets): - thin = set["thin"] - tight = set["tight"] + for i, sset in enumerate(ssets): + thin = sset["thin"] + tight = sset["tight"] factor = 1.75 if tight else 1.25 - well = "clng" in set - space = set["space"] + well = "clng" in sset + space = sset["space"] room = rooms[space.nameString()] h = room["h"] - width = alignedWidth( set["box"], True) - depth = alignedHeight(set["box"], True) - barea = set["om2"] if "om2" in set else set["bm2"] + width = alignedWidth( sset["box"], True) + depth = alignedHeight(sset["box"], True) + barea = sset["om2"] if "om2" in sset else sset["bm2"] rtio = barea / avm2 skym2 = srr2 * barea * rtio - # Flag set if too narrow/shallow to hold a single skylight. + # Flag subset if too narrow/shallow to hold a single skylight. if well: if round(width, 2) < round(wl, 2): - oslg.log(CN.WRN, "set #{i+1} well: Too narrow (%s)" % mth) - set["void"] = True + oslg.log(CN.WRN, "subset #{i+1} well: Too narrow (%s)" % mth) + sset["void"] = True continue if round(depth, 2) < round(wl, 2): - oslg.log(CN.WRN, "set #{i+1} well: Too shallow (%s)" % mth) - set["void"] = True + oslg.log(CN.WRN, "subset #{i+1} well: Too shallow (%s)" % mth) + sset["void"] = True continue else: if round(width, 2) < round(w0, 2): - oslg.log(CN.WRN, "set #{i+1}: Too narrow (%s)" % mth) - set["void"] = True + oslg.log(CN.WRN, "subset #{i+1}: Too narrow (%s)" % mth) + sset["void"] = True continue if round(depth, 2) < round(w0, 2): - oslg.log(CN.WRN, "set #{i+1}: Too shallow (%s)" % mth) - set["void"] = True + oslg.log(CN.WRN, "subset #{i+1}: Too shallow (%s)" % mth) + sset["void"] = True continue # Estimate number of skylight modules per 'pattern'. Default spacing @@ -8233,13 +8233,13 @@ def addSkyLights(spaces=[], opts=dict) -> float: if dX: st["dX"] = dX if dY: st["dY"] = dY - set[pattern] = st + sset[pattern] = st - if not any(pattern in set for pattern in patterns): set["void"] = True + if not any(pattern in sset for pattern in patterns): sset["void"] = True - # Delete voided sets. - sets = [set for set in sets if "void" not in set] - if not sets: return oslg.empty("sets (2)", mth, CN.WRN, rm2) + # Delete voided subsets. + ssets = [sset for sset in ssets if "void" not in sset] + if not ssets: return oslg.empty("subsets (2)", mth, CN.WRN, rm2) # Final reset of filters. if not sidelit: filters = [fil.replace("b", "") for fil in filters] @@ -8258,7 +8258,7 @@ def addSkyLights(spaces=[], opts=dict) -> float: if round(skm2, 2) >= round(sm2, 2): continue dm2 = sm2 - skm2 # differential (remaining skylight area to meet). - sts = [st for st in sets if "pattern" not in st] + sts = [sset for sset in ssets if "pattern" not in sset] if "a" in filter: # Start with the default (ideal) allocation selection: @@ -8333,73 +8333,73 @@ def addSkyLights(spaces=[], opts=dict) -> float: skm2 += fpm2[pattern]["m2"] - # Update matching sets. + # Update matching subsets. for st in sts: - for set in sets: - if pattern not in set: continue - if st["roof"] != set["roof"]: continue - if not areSame(st["box"], set["box"]): continue + for sset in ssets: + if pattern not in sset: continue + if st["roof"] != sset["roof"]: continue + if not areSame(st["box"], sset["box"]): continue if "clng" in st: - if not "clng" in set: continue - if st["clng"] != set["clng"]: continue + if not "clng" in sset: continue + if st["clng"] != sset["clng"]: continue - set["pattern"] = pattern - set["cols" ] = set[pattern]["cols"] - set["rows" ] = set[pattern]["rows"] - set["w" ] = set[pattern]["wx" ] - set["d" ] = set[pattern]["wy" ] - set["w0" ] = set[pattern]["wxl" ] - set["d0" ] = set[pattern]["wyl" ] + sset["pattern"] = pattern + sset["cols" ] = sset[pattern]["cols"] + sset["rows" ] = sset[pattern]["rows"] + sset["w" ] = sset[pattern]["wx" ] + sset["d" ] = sset[pattern]["wy" ] + sset["w0" ] = sset[pattern]["wxl" ] + sset["d0" ] = sset[pattern]["wyl" ] - if set[pattern]["dX"]: set["dX"] = set[pattern]["dX"] - if set[pattern]["dY"]: set["dY"] = set[pattern]["dY"] + if sset[pattern]["dX"]: sset["dX"] = sset[pattern]["dX"] + if sset[pattern]["dY"]: sset["dY"] = sset[pattern]["dY"] # Delete incomplete sets (same as rejected if 'voided'). - sets = [set for set in sets if "void" not in set] - sets = [set for set in sets if "pattern" in set] - if not sets: return oslg.empty("sets (3)", mth, CN.WRN, rm2) + ssets = [sset for sset in ssets if "void" not in sset] + ssets = [sset for sset in ssets if "pattern" in sset] + if not ssets: return oslg.empty("subsets (3)", mth, CN.WRN, rm2) # Skylight size contraction if overshot (e.g. scale down by -13% if > +13%). # Applied on a surface/pattern basis: individual skylight sizes may vary # from one surface to the next, depending on respective patterns. - # First, skip whole sets altogether if their total m2 < (skm2 - sm2). Only - # considered if significant discrepancies vs average set skylight m2. + # First, skip subsets altogether if their total m2 < (skm2 - sm2). Only + # considered if significant discrepancies vs average subset skylight m2. sbm2 = 0 - for set in sets: - sbm2 += set["cols"] * set["w"] * set["rows"] * set["d"] * set["m"] + for sset in ssets: + sbm2 += sset["cols"] * sset["w"] * sset["rows"] * sset["d"] * sset["m"] - avm2 = sbm2 / len(sets) + avm2 = sbm2 / len(ssets) if round(skm2, 2) > round(sm2, 2): - sets.reverse() + ssets.reverse() - for set in sets: + for sset in ssets: if round(skm2, 2) <= round(sm2, 2): break - stm2 = set["cols"] * set["w"] * set["rows"] * set["d"] * set["m"] + stm2 = sset["cols"] * sset["w"] * sset["rows"] * sset["d"] * sset["m"] if round(stm2, 2) >= round(0.75 * avm2, 2): continue if round(stm2, 2) >= round(skm2 - sm2, 2): continue skm2 -= stm2 - set["void"] = True + sset["void"] = True - sets.reverse() + ssets.reverse() - sets = [set for set in sets if "void" not in set] - if not sets: return oslg.empty("sets (4)", mth, CN.WRN, rm2) + ssets = [sset for sset in ssets if "void" not in sset] + if not ssets: return oslg.empty("subsets (4)", mth, CN.WRN, rm2) # Size contraction: round 1: low-hanging fruit. if round(skm2, 2) > round(sm2, 2): ratio2 = 1 - (skm2 - sm2) / skm2 ratio = math.sqrt(ratio2) - for set in sets: - am2 = set["cols"] * set["w"] * set["rows"] * set["d"] * set["m"] - xr = set["w"] - yr = set["d"] + for sset in ssets: + am2 = sset["cols"] * sset["w"] * sset["rows"] * sset["d"] * sset["m"] + xr = sset["w"] + yr = sset["d"] if xr > w0: xr = w0 if xr * ratio < w0 else xr * ratio @@ -8407,39 +8407,39 @@ def addSkyLights(spaces=[], opts=dict) -> float: if yr > w0: yr = w0 if yr * ratio < w0 else yr * ratio - xm2 = set["cols"] * xr * set["rows"] * yr * set["m"] + xm2 = sset["cols"] * xr * sset["rows"] * yr * sset["m"] if round(xm2, 2) == round(am2, 2): continue - set["dY"] += (set["d"] - yr) / 2 - if "dX" in set: set["dX"] += (set["w"] - xr) / 2 + sset["dY"] += (sset["d"] - yr) / 2 + if "dX" in sset: sset["dX"] += (sset["w"] - xr) / 2 - set["w" ] = xr - set["d" ] = yr - set["w0"] = set["w"] + gap - set["d0"] = set["d"] + gap + sset["w" ] = xr + sset["d" ] = yr + sset["w0"] = sset["w"] + gap + sset["d0"] = sset["d"] + gap skm2 -= (am2 - xm2) - # Size contraction: round 2: prioritize larger sets. + # Size contraction: round 2: prioritize larger subsets. adm2 = 0 - for set in sets: - if round(set["w"], 2) <= w0: continue - if round(set["d"], 2) <= w0: continue + for sset in ssets: + if round(sset["w"], 2) <= w0: continue + if round(sset["d"], 2) <= w0: continue - adm2 += set["cols"] * set["w"] * set["rows"] * set["d"] * set["m"] + adm2 += sset["cols"] * sset["w"] * sset["rows"] * sset["d"] * sset["m"] if round(skm2, 2) > round(sm2, 2) and round(adm2, 2) > round(sm2, 2): ratio2 = 1 - (adm2 - sm2) / adm2 ratio = math.sqrt(ratio2) - for set in sets: - if round(set["w"], 2) <= w0: continue - if round(set["d"], 2) <= w0: continue + for sset in ssets: + if round(sset["w"], 2) <= w0: continue + if round(sset["d"], 2) <= w0: continue - am2 = set["cols"] * set["w"] * set["rows"] * set["d"] * set["m"] - xr = set["w"] - yr = set["d"] + am2 = sset["cols"] * sset["w"] * sset["rows"] * sset["d"] * sset["m"] + xr = sset["w"] + yr = sset["d"] if xr > w0: xr = w0 if xr * ratio < w0 else xr * ratio @@ -8447,16 +8447,16 @@ def addSkyLights(spaces=[], opts=dict) -> float: if yr > w0: yr = yw0 if r * ratio < w0 else yr * ratio - xm2 = set["cols"] * xr * set["rows"] * yr * set["m"] + xm2 = sset["cols"] * xr * sset["rows"] * yr * sset["m"] if round(xm2, 2) == round(am2, 2): continue - set["dY"] += (set["d"] - yr) / 2 - if "dX" in set: set["dX"] += (set["w"] - xr) / 2 + sset["dY"] += (sset["d"] - yr) / 2 + if "dX" in sset: sset["dX"] += (sset["w"] - xr) / 2 - set["w" ] = xr - set["d" ] = yr - set["w0"] = set["w"] + gap - set["d0"] = set["d"] + gap + sset["w" ] = xr + sset["d" ] = yr + sset["w0"] = sset["w"] + gap + sset["d0"] = sset["d"] + gap skm2 -= (am2 - xm2) adm2 -= (am2 - xm2) @@ -8466,12 +8466,12 @@ def addSkyLights(spaces=[], opts=dict) -> float: ratio2 = 1 - (skm2 - sm2) / skm2 ratio = math.sqrt(ratio2) - for set in sets: + for sset in ssets: if round(skm2, 2) <= round(sm2, 2): break - am2 = set["cols"] * set["w"] * set["rows"] * set["d"] * set["m"] - xr = set["w"] - yr = set["d"] + am2 = sset["cols"] * sset["w"] * sset["rows"] * sset["d"] * sset["m"] + xr = sset["w"] + yr = sset["d"] if xr > gap4: xr = gap4 if xr * ratio < gap4 else xr * ratio @@ -8479,16 +8479,16 @@ def addSkyLights(spaces=[], opts=dict) -> float: if yr > gap4: yr = gap4 if yr * ratio < gap4 else yr * ratio - xm2 = set["cols"] * xr * set["rows"] * yr * set["m"] + xm2 = sset["cols"] * xr * sset["rows"] * yr * sset["m"] if round(xm2, 2) == round(am2, 2): continue - set["dY"] += (set["d"] - yr) / 2 - if "dX" in set: set["dX"] += (set["w"] - xr) / 2 + sset["dY"] += (sset["d"] - yr) / 2 + if "dX" in sset: sset["dX"] += (sset["w"] - xr) / 2 - set["w" ] = xr - set["d" ] = yr - set["w0"] = set["w"] + gap - set["d0"] = set["d"] + gap + sset["w" ] = xr + sset["d" ] = yr + sset["w0"] = sset["w"] + gap + sset["d0"] = sset["d"] + gap skm2 -= (am2 - xm2) @@ -8502,7 +8502,7 @@ def addSkyLights(spaces=[], opts=dict) -> float: for grenier in greniers.values(): for roof in grenier["roofs"]: - sts = sets + sts = ssets sts = [st for st in sts if "clng" in st] sts = [st for st in sts if k in st] sts = [st for st in sts if "ld" in st] @@ -8554,7 +8554,7 @@ def addSkyLights(spaces=[], opts=dict) -> float: stz = [] for roof in ceiling["roofs"]: - sts = sets + sts = ssets sts = [st for st in sts if "clng" in st] sts = [st for st in sts if k in st] sts = [st for st in sts if "space" in st] @@ -8619,7 +8619,7 @@ def addSkyLights(spaces=[], opts=dict) -> float: grenier_wall.setAdjacentSurface(room_wall) room_wall.setAdjacentSurface(grenier_wall) - # Add individual skylights. Independently of the set layout + # Add individual skylights. Independently of the subset layout # (rows x cols), individual roof inserts may be deeper than # wider (or vice-versa). Adapt skylight width vs depth # accordingly. @@ -8634,7 +8634,7 @@ def addSkyLights(spaces=[], opts=dict) -> float: addSubs(roof, sub, False, True, True) - # Vertically-cast set roof "vtx" onto ceiling. + # Vertically-cast subset roof "vtx" onto ceiling. for st in stz: cst = cast(ti * st["vtx"], t0 * tile.vertices(), ray) st["cvtx"] = t0.inverse() * cst @@ -8653,7 +8653,7 @@ def addSkyLights(spaces=[], opts=dict) -> float: # plenums). No overlaps, so no relative space coordinate adjustments. for ide, room in rooms.items(): for roof in room["roofs"]: - for i, st in enumerate(sets): + for i, st in enumerate(ssets): if "clng" in st: continue if "box" not in st: continue if "cols" not in st: continue diff --git a/tests/test_osut.py b/tests/test_osut.py index 8c069cd..0c1f3c7 100644 --- a/tests/test_osut.py +++ b/tests/test_osut.py @@ -975,8 +975,8 @@ def test09_construction_set(self): model = model.get() for s in model.getSurfaces(): - set = osut.defaultConstructionSet(s) - self.assertTrue(set) + cset = osut.defaultConstructionSet(s) + self.assertTrue(cset) self.assertEqual(o.status(), 0) del model @@ -988,8 +988,8 @@ def test09_construction_set(self): model = model.get() for s in model.getSurfaces(): - set = osut.defaultConstructionSet(s) - self.assertFalse(set) + cset = osut.defaultConstructionSet(s) + self.assertFalse(cset) self.assertTrue(o.is_warn()) for l in o.logs(): self.assertEqual(l["message"], m) @@ -1863,7 +1863,7 @@ def test20_setpoints_plenums_attics(self): mt1 = "(osut.arePlenums)" mt2 = "(osut.hasHeatingTemperatureSetpoints)" mt3 = "(osut.setpoints)" - ms1 = "'set' NoneType? expecting list %s" % mt1 + ms1 = "'spaces' NoneType? expecting list %s" % mt1 ms2 = "'model' NoneType? expecting %s %s" % (cl2.__name__, mt2) ms3 = "'space' Nonetype? expecting %s %s" % (cl1.__name__, mt3) From fb86a4b5fc400ef9fa677ab154a0dee05b354bf9 Mon Sep 17 00:00:00 2001 From: brgix Date: Sat, 2 Aug 2025 08:46:34 -0400 Subject: [PATCH 43/49] First 'addSkylights' test 'green' (more to come) --- src/osut/osut.py | 82 +++++++++++++++++++++++++--------------------- tests/test_osut.py | 5 ++- 2 files changed, 47 insertions(+), 40 deletions(-) diff --git a/src/osut/osut.py b/src/osut/osut.py index 5d0ca6b..860df80 100644 --- a/src/osut/osut.py +++ b/src/osut/osut.py @@ -5204,11 +5204,9 @@ def spaceHeight(space=None) -> float: # The solution considers all surface types: "Floor", "Wall", "RoofCeiling". # No presumption that floor are necessarily at ground level. for surface in space.surfaces(): - for pts in surface.vertices(): - zs = [pt.z() for pt in pts] - - minZ = min(minZ, min(zs)) - maxZ = max(maxZ, max(zs)) + zs = [pt.z() for pt in surface.vertices()] + minZ = min(minZ, min(zs)) + maxZ = max(maxZ, max(zs)) hght = maxZ - minZ if hght < 0: hght = 0 @@ -5436,7 +5434,7 @@ def genAnchors(s=None, sset=[], tag="box") -> int: if ids not in other["ld"]: continue ost = other[tag] - pld = other["ld"][ts] + pld = other["ld"][ids] if areSame(pld, pt): continue if doesLineIntersect(ld, [pld, ost[0]]): nb += 1 @@ -5446,7 +5444,7 @@ def genAnchors(s=None, sset=[], tag="box") -> int: if holds(sg, tpts[0]): continue if doesLineIntersect(sg, ld): nb += 1 - if (sg[0] - sg[0]).cross(ld[0] - ld[0]).length() < TOL: nb += 1 + if ((sg[0]-sg[-1]).cross(ld[0]-ld[-1])).length() < CN.TOL: nb += 1 if nb == 0: candidates.append(pt) @@ -5465,9 +5463,9 @@ def genAnchors(s=None, sset=[], tag="box") -> int: st["ld"][ids] = p0 n += 1 else: - str = ide + ("subset #%d" % (i+1)) - m = "%s: unable to anchor %s leader line (%s)" % (str, tag, mth) - oslg.log(WRN, m) + str1 = ide + ("subset #%d" % (i+1)) + m = "%s: unable to anchor '%s' leader line (%s)" % (str1, tag, mth) + oslg.log(CN.WRN, m) st["void"] = True return n @@ -6931,7 +6929,7 @@ def grossRoofArea(spaces=[]) -> float: return rm2 -def getHorizontalRidges(rufs=[]) -> list: +def horizontalRidges(rufs=[]) -> list: """Identifies horizontal ridges along 2x sloped 'roof' surfaces (same space) - see 'isRoof'. Harmonized with OpenStudio's "alignZPrime" - see 'isSloped'. @@ -6947,7 +6945,7 @@ def getHorizontalRidges(rufs=[]) -> list: - "roofs" (list): 2x linked roof surfaces, on either side of the edge """ - mth = "osut.getHorizontalRidges" + mth = "osut.horizontalRidges" ridges = [] try: @@ -6998,7 +6996,8 @@ def getHorizontalRidges(rufs=[]) -> list: if match: break edg1 = list(edg) - edg2 = edg.reverse() + edg2 = list(edg) + edg2.reverse() if areSame(edge, edg1) or areSame(edge, edg2): ridge["roofs"].append(ruf) @@ -7437,7 +7436,6 @@ def addSkyLights(spaces=[], opts=dict) -> float: # By default, seek ideal candidate spaces/roofs. Bail out if unsuccessful. if opts["ration"] is True: spaces = toToplit(spaces, opts) - print(spaces.__class__.__name__) if not spaces: return rm2 # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # @@ -7605,7 +7603,7 @@ def addSkyLights(spaces=[], opts=dict) -> float: # - no skylight wells (i.e. no leader lines) # - 1x skylight array per roof surface # - no need to consider site transformation - for roof in rooms[space]["roofs"]: + for roof in rooms[ide]["roofs"]: if not isRoof(roof): continue vtx = roof.vertices() @@ -7640,6 +7638,7 @@ def addSkyLights(spaces=[], opts=dict) -> float: sset["space" ] = space sset["m" ] = space.multiplier() sset["sidelit"] = rooms[space]["sidelit"] + sset["sloped" ] = isSloped(roof) sset["t0" ] = rooms[space]["t0"] sset["t" ] = openstudio.Transformation.alignFace(vtx) ssets.append(sset) @@ -7648,7 +7647,7 @@ def addSkyLights(spaces=[], opts=dict) -> float: for ide, room in rooms.items(): t0 = room["t0"] space = room["space"] - rufs = roofs(space) - room["roofs"] + rufs = [ruf for ruf in roofs(space) if ruf not in room["roofs"]] for ruf in rufs: id0 = ruf.nameString() @@ -7777,6 +7776,7 @@ def addSkyLights(spaces=[], opts=dict) -> float: sset["ti" ] = ti sset["t" ] = openstudio.Transformation.alignFace(vtx) sset["sidelit"] = room["sidelit"] + sset["sloped" ] = isSloped(ruf) if isUnconditioned(espace): # e.g. attic if idx not in attics: # idx = espace.nameString() @@ -7814,11 +7814,15 @@ def addSkyLights(spaces=[], opts=dict) -> float: # Ensure uniqueness of plenum roofs. for attic in attics.values(): - attic["roofs" ] = list(set(attic["roofs"])) + ruufs = [] + ruufs = [ruf for ruf in attic["roofs"] if ruf not in ruufs] + attic["roofs" ] = ruufs attic["ridges"] = horizontalRidges(attic["roofs"]) # @todo for plenum in plenums.values(): - plenum["roofs" ] = list(set(plenum["roofs"])) + ruufs = [] + ruufs = [ruf for ruf in plenum["roofs"] if ruf not in ruufs] + plenum["roofs" ] = ruufs plenum["ridges"] = horizontalRidges(plenum["roofs"]) # @todo # Regardless of the selected skylight arrangement pattern, the solution only @@ -7851,12 +7855,10 @@ def addSkyLights(spaces=[], opts=dict) -> float: k = "attic" if "attic" in ceiling else "plenum" if k not in ceiling: continue - clng = ceiling["cnlg" ] # ceiling surface + clng = ceiling["clng" ] # ceiling surface space = ceiling["space"] # its space espace = ceiling[k] # adjacent (unoccupied) space above - if "roofs" not in ceiling: continue - if ceiling["ide"] not in rooms: continue stz = [] @@ -7989,11 +7991,11 @@ def addSkyLights(spaces=[], opts=dict) -> float: if round(ly, 2) < round(sp, 2): continue if well: - cols = (width / (wxl + sp)).round(2).to_i - rows = (depth / (wyl + sp)).round(2).to_i + cols = int(round(width / (wxl + sp)), 2) + rows = int(round(depth / (wyl + sp)), 2) else: - cols = (width / (wx + sp)).round(2).to_i - rows = (depth / (wy + sp)).round(2).to_i + cols = int(round(width / (wx + sp)), 2) + rows = int(round(depth / (wy + sp)), 2) if cols < 2: continue if rows < 2: continue @@ -8211,12 +8213,12 @@ def addSkyLights(spaces=[], opts=dict) -> float: if well: wyl = wy + gap ly = depth - wyl - ly = sp if ly.round(2) < sp.round(2) else ly + ly = sp if round(ly, 2) < round(sp, 2) else ly wyl = depth - ly wy = wyl - gap else: ly = depth - wy - ly = sp if ly.round(2) < sp.round(2) else ly + ly = sp if round(ly, 2) < round(sp, 2) else ly wy = depth - ly dY = ly / 2 @@ -8248,7 +8250,9 @@ def addSkyLights(spaces=[], opts=dict) -> float: if not attics: filters = [fil.replace("e", "") for fil in filters] filters = [fil for fil in filters if fil] # remove any empty filter strings - filters = list(set(filters)) # ensure uniqueness + flters = [] + flters = [fil for fil in filters if fil not in flters] # ensure uniqueness + filters = flters # Initialize skylight area tally (to increment). skm2 = 0 @@ -8303,11 +8307,11 @@ def addSkyLights(spaces=[], opts=dict) -> float: if not fpm2["tight"]: pattern = "array" if not pattern: - fpm2 = dict(sorted(f2.items(), key=lambda f2: f2[1]["m2"])) - mnM2 = fpm2.values()[ 0]["m2"] - mxM2 = fpm2.values()[-1]["m2"] + fpm2 = dict(sorted(fpm2.items(), key=lambda f2: f2[1]["m2"])) + mnM2 = list(fpm2.values())[ 0]["m2"] + mxM2 = list(fpm2.values())[-1]["m2"] - if mnM2.round(2) >= dm2.round(2): + if round(mnM2, 2) >= round(dm2, 2): # If not large array, then retain pattern generating smallest # skylight area if ALL patterns >= residual target # (deterministic sorting). @@ -8352,8 +8356,10 @@ def addSkyLights(spaces=[], opts=dict) -> float: sset["w0" ] = sset[pattern]["wxl" ] sset["d0" ] = sset[pattern]["wyl" ] - if sset[pattern]["dX"]: sset["dX"] = sset[pattern]["dX"] - if sset[pattern]["dY"]: sset["dY"] = sset[pattern]["dY"] + if "dX" in sset[pattern] and sset[pattern]["dX"]: + sset["dX"] = sset[pattern]["dX"] + if "dY" in sset[pattern] and sset[pattern]["dY"]: + sset["dY"] = sset[pattern]["dY"] # Delete incomplete sets (same as rejected if 'voided'). ssets = [sset for sset in ssets if "void" not in sset] @@ -8510,8 +8516,10 @@ def addSkyLights(spaces=[], opts=dict) -> float: sts = [st for st in sts if "roof" in st] sts = [st for st in sts if "pattern" in st] sts = [st for st in sts if st["pattern"] in st] - sts = [st for st in sts if st["space" ] in rooms] - sts = [st for st in sts if id(roof) in st["ld"]] + + ide = st["space"].nameString() + sts = [st for st in sts if ide in rooms] + sts = [st for st in sts if id(roof) in st["ld"]] sts = [st for st in sts if st[k ] == grenier["space"]] sts = [st for st in sts if st["roof"] == roof] @@ -8540,7 +8548,7 @@ def addSkyLights(spaces=[], opts=dict) -> float: espace = ceiling[k ] # (unoccupied) space above ceiling floor = ceiling["floor"] # adjacent floor above - clng = ceiling["cnlg" ] # ceiling surface + clng = ceiling["clng" ] # ceiling surface space = ceiling["space"] # its space idx = espace.nameString() ide = space.nameString() diff --git a/tests/test_osut.py b/tests/test_osut.py index 0c1f3c7..c80e20b 100644 --- a/tests/test_osut.py +++ b/tests/test_osut.py @@ -4991,9 +4991,8 @@ def test34_generated_skylight_wells(self): self.assertAlmostEqual(gra_attic, 157.77, places=2) # The method returns the GRA, calculated BEFORE adding skylights/wells. - # rm2 = osut.addSkyLights(core, dict(srr=srr)) - # print(o.logs()) - # self.assertAlmostEqual(rm2, gra_attic, places=2) + rm2 = osut.addSkyLights(core, dict(srr=srr)) + self.assertAlmostEqual(rm2, gra_attic, places=2) self.assertEqual(o.status(), 0) del model From 0d9984713b595714f9bcd2813cf24d06e8ced8dc Mon Sep 17 00:00:00 2001 From: brgix Date: Sun, 3 Aug 2025 07:31:24 -0400 Subject: [PATCH 44/49] Fixes (inter alia) ceiling leader lines (skylights) --- src/osut/osut.py | 70 ++++++++++++++++++++++++---------------------- tests/test_osut.py | 7 +++++ 2 files changed, 44 insertions(+), 33 deletions(-) diff --git a/src/osut/osut.py b/src/osut/osut.py index 860df80..95c7889 100644 --- a/src/osut/osut.py +++ b/src/osut/osut.py @@ -2136,8 +2136,8 @@ def arePlenums(spaces=None): # By initially relying on the SDK's "partofTotalFloorArea", "space_plenum?" # ends up catching a MUCH WIDER range of spaces, which aren't caught by # "isPlenum". This includes attics, crawlspaces, non-plenum air spaces above - # ceiling tiles, and any other UNOCCUPIED space in a model. The term - # "plenum" in this context is more of a catch-all shorthand - to be used + # ceiling surfaces, and any other UNOCCUPIED space in a model. The term + # "plenum" in that context is more of a catch-all shorthand - to be used # with caution. For instance, "space_plenum?" shouldn't be used (in # isolation) to determine whether an UNOCCUPIED space should have its # envelope insulated ("plenum") or not ("attic"). @@ -5371,12 +5371,11 @@ def genAnchors(s=None, sset=[], tag="box") -> int: # others) a 'realigned' set of points (by default a 'realigned' "box"). # The latter is typically generated from an outdoor-facing roof. # Subsequent calls to 'genAnchors' may send (as first argument) a - # corresponding ceiling tile below (both may be called from - # 'addSkylights'). Roof vs ceiling may neither share alignment - # transformation nor space/site transformation identities. All - # subsequent calls to 'genAnchors' shall recover the "out" points, - # apply a succession of de/alignments and transformations in sync, and - # overwrite tagged points. + # corresponding ceiling below (both may be called from 'addSkylights'). + # Roof vs ceiling may neither share alignment transformation nor + # space/site transformation identities. All subsequent calls to + # 'genAnchors' shall recover the "out" points, apply a succession of + # de/alignments and transformations in sync, and overwrite tagged points. # # Although 'genAnchors' and 'genInserts' have both been developed to # support anchor insertions in other cases (e.g. bay window in a wall), @@ -7297,7 +7296,7 @@ def addSkyLights(spaces=[], opts=dict) -> float: if not spaces: return oslg.empty("spaces", mth, CN.DBG, 0) - mdl = spaces[0].model + mdl = spaces[0].model() # Exit if mismatched or invalid options. if not isinstance(opts, dict): @@ -7676,9 +7675,9 @@ def addSkyLights(spaces=[], opts=dict) -> float: # Process occupied room ceilings, as 1x or more are overlapping roof # surfaces above. Vertically cast, then fetch overlap. - for tile in facets(space, "Surface", "RoofCeiling"): - idee = tile.nameString() - tpts = t0 * tile.vertices() + for clng in facets(space, "Surface", "RoofCeiling"): + idee = clng.nameString() + tpts = t0 * clng.vertices() ci0 = cast(tpts, rpts, ray) if not ci0: continue @@ -7697,7 +7696,7 @@ def addSkyLights(spaces=[], opts=dict) -> float: # Adding skylight wells (plenums/attics) is contingent to safely # linking new base roof 'inserts' (as well as new ceiling ones) # through 'leader lines'. This requires an offset to ensure no - # conflicts with roof or (ceiling) tile edges. + # conflicts with roof or ceiling edges. # # @todo: Expand the method to factor in cases where simple # 'side' cutouts can be supported (no need for leader @@ -7716,7 +7715,7 @@ def addSkyLights(spaces=[], opts=dict) -> float: if width < wl * 3: continue if depth < wl * 2: continue - # Vertically cast box onto tile below. + # Vertically cast box onto ceiling below. cbox = cast(box, tpts, ray) if not cbox: continue @@ -7728,7 +7727,7 @@ def addSkyLights(spaces=[], opts=dict) -> float: cbox = t0.inverse() * cbox if idee not in ceilings: - floor = tile.adjacentSurface() + floor = clng.adjacentSurface() if not floor: oslg.log(CN.ERR, "%s adjacent floor? (%s)" % (idee, mth)) continue @@ -7745,7 +7744,7 @@ def addSkyLights(spaces=[], opts=dict) -> float: continue ceilings[idee] = {} # idee: ceiling surface ID - ceilings[idee]["clng" ] = tile # ceiling surface itself + ceilings[idee]["clng" ] = clng # ceiling surface itself ceilings[idee]["id" ] = ide # its space's ID ceilings[idee]["space"] = space # its space ceilings[idee]["floor"] = floor # adjacent floor @@ -7771,7 +7770,7 @@ def addSkyLights(spaces=[], opts=dict) -> float: sset["roof" ] = ruf sset["space" ] = space sset["m" ] = space.multiplier() - sset["clng" ] = tile + sset["clng" ] = clng sset["t0" ] = t0 sset["ti" ] = ti sset["t" ] = openstudio.Transformation.alignFace(vtx) @@ -7883,7 +7882,7 @@ def addSkyLights(spaces=[], opts=dict) -> float: if not stz: continue stz = sorted(stz, key=lambda st: st["cm2"], reverse=True) - genAnchors(tile, stz, "cbox") + genAnchors(clng, stz, "cbox") # Delete voided sets. ssets = [sset for sset in ssets if "void" not in sset] @@ -8563,25 +8562,29 @@ def addSkyLights(spaces=[], opts=dict) -> float: for roof in ceiling["roofs"]: sts = ssets - sts = [st for st in sts if "clng" in st] - sts = [st for st in sts if k in st] - sts = [st for st in sts if "space" in st] - sts = [st for st in sts if "roof" in st] - sts = [st for st in sts if "pattern" in st] - sts = [st for st in sts if "cm2" in st] - sts = [st for st in sts if "vts" in st] - sts = [st for st in sts if "vtx" in st] - sts = [st for st in sts if "ld" in st] - sts = [st for st in sts if id(roof) in st["ld"]] - sts = [st for st in sts if id(clng) in st["ld"]] - sts = [st for st in sts if st["space"] in rooms] + sts = [st for st in sts if "clng" in st] + sts = [st for st in sts if k in st] + sts = [st for st in sts if "space" in st] + sts = [st for st in sts if "roof" in st] + sts = [st for st in sts if "pattern" in st] + sts = [st for st in sts if "cm2" in st] + sts = [st for st in sts if "vts" in st] + sts = [st for st in sts if "vtx" in st] + sts = [st for st in sts if "ld" in st] + sts = [st for st in sts if id(roof) in st["ld"]] + sts = [st for st in sts if id(clng) in st["ld"]] + + id0 = st["space"].nameString() + + sts = [st for st in sts if id0 == ide] + sts = [st for st in sts if id0 in rooms] sts = [st for st in sts if st["clng"] == clng] sts = [st for st in sts if st["roof"] == roof] sts = [st for st in sts if st[k ] == espace] if len(sts) != 1: continue - stz.append(sts)[0] + stz.append(sts[0]) if not stz: continue @@ -8593,7 +8596,8 @@ def addSkyLights(spaces=[], opts=dict) -> float: if frame: sub["frame"] = frame for ids, vt in st["vts"].items(): - roof = openstudio.model.Surface(t0.inverse() * (ti * vt), mdl) + vec = p3Dv(t0.inverse() * list(ti * vt)) + roof = openstudio.model.Surface(vec, mdl) roof.setSpace(space) roof.setName("%s:%s" % (ids, ide)) @@ -8644,7 +8648,7 @@ def addSkyLights(spaces=[], opts=dict) -> float: # Vertically-cast subset roof "vtx" onto ceiling. for st in stz: - cst = cast(ti * st["vtx"], t0 * tile.vertices(), ray) + cst = cast(ti * st["vtx"], t0 * clng.vertices(), ray) st["cvtx"] = t0.inverse() * cst # Extended ceiling vertices. diff --git a/tests/test_osut.py b/tests/test_osut.py index c80e20b..510f78e 100644 --- a/tests/test_osut.py +++ b/tests/test_osut.py @@ -4994,6 +4994,13 @@ def test34_generated_skylight_wells(self): rm2 = osut.addSkyLights(core, dict(srr=srr)) self.assertAlmostEqual(rm2, gra_attic, places=2) + # New core skylight areas. Successfully achieved SRR%. + core_skies = osut.facets(core, "Outdoors", "Skylight") + sky_area1 = sum([sk.grossArea() for sk in core_skies]) + self.assertAlmostEqual(round(sky_area1, 2), 7.89) + ratio = sky_area1 / rm2 + self.assertAlmostEqual(round(ratio, 2), srr) + self.assertEqual(o.status(), 0) del model From 52dc07904ffccecd4c99ccc315bf48414147eda1 Mon Sep 17 00:00:00 2001 From: brgix Date: Sun, 3 Aug 2025 09:41:34 -0400 Subject: [PATCH 45/49] Fixes roof leader line issues --- src/osut/osut.py | 22 ++++++++++++---------- tests/test_osut.py | 9 +++++++++ 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/src/osut/osut.py b/src/osut/osut.py index 95c7889..97c7ce8 100644 --- a/src/osut/osut.py +++ b/src/osut/osut.py @@ -5315,8 +5315,8 @@ def genAnchors(s=None, sset=[], tag="box") -> int: mth = "osut.genAnchors" n = 0 ide = "%s " % s.nameString() if hasattr(s, "nameString") else "" - pts = poly(s) ids = id(s) + pts = poly(s) if not pts: return oslg.invalid("%s polygon" % ide, mth, 1, CN.DBG, n) @@ -5361,7 +5361,7 @@ def genAnchors(s=None, sset=[], tag="box") -> int: if not isinstance(st["ld"], dict): return oslg.invalid("%s leaders" % str1, mth, 0, CN.DBG, n) - st["ld"] = dict(st["ld"].items(), key=lambda k: k[0] != ids) + if ids in st["ld"]: st["ld"].pop(ids) else: st["ld"] = {} @@ -5496,8 +5496,8 @@ def genExtendedVertices(s=None, sset=[], tag="vtx") -> openstudio.Point3dVector: mth = "osut.genExtendedVertices" ide = "%s " % s.nameString() if hasattr(s, "nameString") else "" f = False - pts = poly(s) ids = id(s) + pts = poly(s) cl = openstudio.Point3d a = openstudio.Point3dVector() v = [] @@ -5590,6 +5590,7 @@ def genInserts(s=None, sset=[]) -> openstudio.Point3dVector: """ mth = "osut.genInserts" ide = "%s:" % s.nameString() if hasattr(s, "nameString") else "" + ids = id(s) pts = poly(s) cl = openstudio.Point3d a = openstudio.Point3dVector() @@ -5622,10 +5623,10 @@ def genInserts(s=None, sset=[]) -> openstudio.Point3dVector: if not isinstance(ld, dict): return oslg.mismatch(str2, "ld", dict, mth, CN.DBG, a) - if id(s) not in ld: - return oslg.hashkey(str2, ld, s, mth, CN.DBG, a) - if not isinstance(ld[id(s)], cl): - return oslg.mismatch(str2, ld[id(s)], cl, mth, CN.DBG, a) + if ids not in ld: + return oslg.hashkey(str2, ld, ide, mth, CN.DBG, a) + if not isinstance(ld[ids], cl): + return oslg.mismatch(str2, ld[ids], cl, mth, CN.DBG, a) # Ensure each subset bounding box is safely within larger polygon # boundaries. @@ -5709,7 +5710,7 @@ def genInserts(s=None, sset=[]) -> openstudio.Point3dVector: for j, other in enumerate(sset): if i == j: continue - bx2 = other["box"] + bx2 = other["box"] if overlapping(bx, bx2): str4 = ide + "subset boxes #%d:#%d" % (i+1, j+1) @@ -5811,7 +5812,7 @@ def genInserts(s=None, sset=[]) -> openstudio.Point3dVector: # Add reverse vertices, circumscribing each insert. vec.reverse() - if iX == cols - 1: vec.pop + if iX == cols - 1: vec.pop() vtx += vec if iX != cols - 1: xC -= gX + x @@ -5824,7 +5825,7 @@ def genInserts(s=None, sset=[]) -> openstudio.Point3dVector: st["vtx"] = p3Dv(t * (o["r"] * (o["t"] * vtx))) # Extended vertex sequence of the larger polygon. - genExtendedVertices(s, sset) + return genExtendedVertices(s, sset) def facets(spaces=[], boundary="all", type="all", sides=[]) -> list: @@ -8522,6 +8523,7 @@ def addSkyLights(spaces=[], opts=dict) -> float: sts = [st for st in sts if st[k ] == grenier["space"]] sts = [st for st in sts if st["roof"] == roof] + if not sts: continue # If successful, 'genInserts' returns extended ROOF surface diff --git a/tests/test_osut.py b/tests/test_osut.py index 510f78e..36b6f50 100644 --- a/tests/test_osut.py +++ b/tests/test_osut.py @@ -5001,6 +5001,15 @@ def test34_generated_skylight_wells(self): ratio = sky_area1 / rm2 self.assertAlmostEqual(round(ratio, 2), srr) + # Reset attic default construction set for insulated interzone walls. + opts = dict(type="partition", uo=0.3) + construction = osut.genConstruction(model, opts) + self.assertAlmostEqual(osut.rsi(construction, 0.150), 1/0.3, places=2) + self.assertTrue(ia_set.setWallConstruction(construction)) + if o.logs(): print(o.logs()) + + model.save("./tests/files/osms/out/office_attic.osm", True) + self.assertEqual(o.status(), 0) del model From a6bca1b2d0563e6f0df58ec5729c6a6718063e37 Mon Sep 17 00:00:00 2001 From: brgix Date: Sun, 3 Aug 2025 10:48:31 -0400 Subject: [PATCH 46/49] Completes 'SmallOffice' skylight unittests --- tests/test_osut.py | 176 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 175 insertions(+), 1 deletion(-) diff --git a/tests/test_osut.py b/tests/test_osut.py index 36b6f50..5b2c9e1 100644 --- a/tests/test_osut.py +++ b/tests/test_osut.py @@ -51,7 +51,7 @@ def test01_osm_instantiation(self): model = openstudio.model.Model() self.assertTrue(isinstance(model, openstudio.model.Model)) del model - + def test02_tuples(self): self.assertEqual(len(osut.sidz()), 6) self.assertEqual(len(osut.mass()), 4) @@ -5010,6 +5010,180 @@ def test34_generated_skylight_wells(self): model.save("./tests/files/osms/out/office_attic.osm", True) + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + # Side test/comment: Why is it necessary to have 'addSkylights' return + # gross roof area (see 'rm2' above)? + # + # First, retrieving (newly-added) core roofs (i.e. skylight base + # surfaces). + rfs1 = osut.facets(core, "Outdoors", "RoofCeiling") + tot1 = sum([sk.grossArea() for sk in rfs1]) + net = sum([sk.netArea() for sk in rfs1]) + self.assertEqual(len(rfs1), 4) + self.assertAlmostEqual(tot1, 9.06, places=2) # 4x 2.265 m2 + self.assertAlmostEqual(tot1 - net, sky_area1, places=2) + + # In absence of skylight wells (more importantly, in absence of leader + # lines anchoring skylight base surfaces), OSut's 'roofs' & + # 'grossRoofArea' report not only on newly-added base surfaces (or + # their areas), but also overalpping areas of attic roofs above. + # Unfortunately, these become unreliable with newly-added skylight wells. + rfs2 = osut.roofs(core) + tot2 = sum([sk.grossArea() for sk in rfs2]) + self.assertAlmostEqual(tot2, tot1, places=2) + self.assertAlmostEqual(tot2, osut.grossRoofArea(core), places=2) + + # Fortunately, the addition of leader lines does not affect how + # OpenStudio reports surface areas. + rfs3 = osut.facets(attic, "Outdoors", "RoofCeiling") + tot3 = sum([sk.grossArea() for sk in rfs3]) + self.assertAlmostEqual(tot3 + tot2, total2, places=2) # 598.76 + + # However, as discussed elsewhere (see 'addSkylights' doctring and + # inline comments), these otherwise valid areas are often overestimated + # for SRR% calculations (e.g. when overhangs and soffits are explicitely + # modelled). It is for this reason 'addSkylights' reports gross roof + # area BEFORE adding skylight wells. For higher-level applications + # relying on 'addSkylights' (e.g. an OpenStudio measure), it is better + # to store returned gross roof areas for subsequent reporting purposes. + + # Deeper dive: Why are OSut's 'roofs' and 'grossRoofArea' unreliable + # with leader lines? Both rely on OSut's 'overlapping', itself relying + # on OpenStudio's 'join' and 'intersect': if neither are successful in + # joining (or intersecting) 2x polygons (e.g. attic roof vs cast core + # ceiling), there can be no identifiable overlap. In such cases, both + # 'roofs' and 'grossRoofArea' ignore overlapping attic roofs. A demo: + roof_north = model.getSurfaceByName("Attic_roof_north") + core_ceiling = model.getSurfaceByName("Core_ZN_ceiling") + self.assertTrue(roof_north) + self.assertTrue(core_ceiling) + roof_north = roof_north.get() + core_ceiling = core_ceiling.get() + + t = openstudio.Transformation.alignFace(roof_north.vertices()) + up = openstudio.Point3d(0,0,1) - openstudio.Point3d(0,0,0) + + a_roof_north = t.inverse() * roof_north.vertices() + a_core_ceiling = t.inverse() * core_ceiling.vertices() + c_core_ceiling = osut.cast(a_core_ceiling, a_roof_north, up) + + north_m2 = openstudio.getArea(a_roof_north) + ceiling_m2 = openstudio.getArea(c_core_ceiling) + self.assertTrue(north_m2) + self.assertTrue(ceiling_m2) + self.assertAlmostEqual(north_m2.get(), 192.98, places=2) + self.assertAlmostEqual(ceiling_m2.get(), 133.81, places=2) + + # So far so good. Ensure clockwise winding. + a_roof_north = list(a_roof_north) + c_core_ceiling = list(c_core_ceiling) + a_roof_north.reverse() + c_core_ceiling.reverse() + self.assertFalse(openstudio.join(a_roof_north, c_core_ceiling, TOL2)) + self.assertFalse(openstudio.intersect(a_roof_north, c_core_ceiling, TOL)) + + # A future revision of OSut's 'roofs' and 'grossRoofArea' would require: + # - a new method identifying leader lines amongst surface vertices + # - a new method identifying surface cutouts amongst surface vertices + # - a method to prune both leader lines and cutouts from surface vertices + # - have 'roofs' & 'grossRoofArea' rely on the remaining outer vertices + # ... @todo? + self.assertEqual(o.status(), 0) + del model + + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + # CASE 2: + path = openstudio.path("./tests/files/osms/in/smalloffice.osm") + model = translator.loadModel(path) + self.assertTrue(model) + model = model.get() + + core = model.getSpaceByName("Core_ZN") + attic = model.getSpaceByName("Attic") + self.assertTrue(core) + self.assertTrue(attic) + core = core.get() + attic = attic.get() + + # Tag attic as an INDIRECTLY-CONDITIONED space. + key = "indirectlyconditioned" + val = core.nameString() + self.assertTrue(attic.additionalProperties().setFeature(key, val)) + self.assertFalse(osut.arePlenums(attic)) + self.assertFalse(osut.isUnconditioned(attic)) + self.assertAlmostEqual(osut.setpoints(attic)["heating"], 21.11, places=2) + self.assertAlmostEqual(osut.setpoints(attic)["cooling"], 23.89, places=2) + + # Here, GRA includes ALL plenum roof surfaces (not just vertically-cast + # roof areas onto the core ceiling). More roof surfaces == greater + # skylight areas to meet the SRR% of 5%. + gra_plenum = osut.grossRoofArea(core) + self.assertAlmostEqual(gra_plenum, total1, places=2) + + rm2 = osut.addSkyLights(core, dict(srr=srr)) + if o.logs(): print(o.logs()) + self.assertAlmostEqual(rm2, total1, places=2) + + # The total skylight area is greater than in CASE 1. Nonetheless, the + # method is able to meet the requested SRR 5%. This may not be + # achievable in other circumstances, given the constrained roof/core + # overlap. Although a plenum vastly larger than the room(s) it serves is + # rare, it remains certainly problematic for the application of the + # Canadian NECB reference building skylight requirements. + core_skies = osut.facets(core, "Outdoors", "Skylight") + sky_area2 = sum([sk.grossArea() for sk in core_skies]) + self.assertAlmostEqual(sky_area2, 29.94, places=2) + ratio2 = sky_area2 / rm2 + self.assertAlmostEqual(ratio2, srr, places=2) + + model.save("./tests/files/osms/out/office_plenum.osm", True) + + self.assertEqual(o.status(), 0) + del model + + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + # CASE 2b: + path = openstudio.path("./tests/files/osms/in/smalloffice.osm") + model = translator.loadModel(path) + self.assertTrue(model) + model = model.get() + + core = model.getSpaceByName("Core_ZN") + attic = model.getSpaceByName("Attic") + self.assertTrue(core) + self.assertTrue(attic) + core = core.get() + attic = attic.get() + + # Again, tagging attic as an INDIRECTLY-CONDITIONED space. + key = "indirectlyconditioned" + val = core.nameString() + self.assertTrue(attic.additionalProperties().setFeature(key, val)) + self.assertFalse(osut.arePlenums(attic)) + self.assertFalse(osut.isUnconditioned(attic)) + self.assertAlmostEqual(osut.setpoints(attic)["heating"], 21.11, places=2) + self.assertAlmostEqual(osut.setpoints(attic)["cooling"], 23.89, places=2) + + gra_plenum = osut.grossRoofArea(core) + self.assertAlmostEqual(gra_plenum, total1, places=2) + + # Conflicting argument case: Here, skylight wells must traverse plenums + # (in this context, "plenum" is an all encompassing keyword for any + # INDIRECTLY-CONDITIONED, unoccupied space). Yet by passing option + # "plenum: False", the method is instructed to skip "plenum" skylight + # wells altogether. + rm2 = osut.addSkyLights(core, dict(srr=srr, plenum=False)) + self.assertTrue(o.is_warn()) + self.assertEqual(len(o.logs()), 1) + msg = o.logs()[0]["message"] + self.assertTrue("Empty 'subsets (3)' (osut.addSkyLights)" in msg) + self.assertAlmostEqual(rm2, total1, places=2) + + core_skies = osut.facets(core, "Outdoors", "Skylight") + sky_area2 = sum([sk.grossArea() for sk in core_skies]) + self.assertAlmostEqual(sky_area2, 0.00, places=2) + self.assertEqual(o.clean(), DBG) + self.assertEqual(o.status(), 0) del model From 60f9f6fd764a605b8e3e2609a87a2578d81c286b Mon Sep 17 00:00:00 2001 From: brgix Date: Sun, 3 Aug 2025 11:25:14 -0400 Subject: [PATCH 47/49] Completes 'SEB' addSkylights unittest --- tests/test_osut.py | 98 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 97 insertions(+), 1 deletion(-) diff --git a/tests/test_osut.py b/tests/test_osut.py index 5b2c9e1..4511924 100644 --- a/tests/test_osut.py +++ b/tests/test_osut.py @@ -51,7 +51,7 @@ def test01_osm_instantiation(self): model = openstudio.model.Model() self.assertTrue(isinstance(model, openstudio.model.Model)) del model - + def test02_tuples(self): self.assertEqual(len(osut.sidz()), 6) self.assertEqual(len(osut.mass()), 4) @@ -5187,6 +5187,102 @@ def test34_generated_skylight_wells(self): self.assertEqual(o.status(), 0) del model + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + # SEB case (flat ceiling plenum). + path = openstudio.path("./tests/files/osms/out/seb2.osm") + model = translator.loadModel(path) + self.assertTrue(model) + model = model.get() + + entry = model.getSpaceByName("Entry way 1") + office = model.getSpaceByName("Small office 1") + open = model.getSpaceByName("Open area 1") + utility = model.getSpaceByName("Utility 1") + plenum = model.getSpaceByName("Level 0 Ceiling Plenum") + self.assertTrue(entry) + self.assertTrue(office) + self.assertTrue(open) + self.assertTrue(utility) + self.assertTrue(plenum) + entry = entry.get() + office = office.get() + open = open.get() + utility = utility.get() + plenum = plenum.get() + self.assertFalse(plenum.partofTotalFloorArea()) + self.assertFalse(osut.isUnconditioned(plenum)) + + # TOTAL plenum roof area (4x surfaces), no overhangs. + roofs = osut.facets(plenum, "Outdoors", "RoofCeiling") + total = sum([ruf.grossArea() for ruf in roofs]) + self.assertAlmostEqual(total, 82.21, places=2) + + # A single plenum above all 4 occupied rooms. Reports same GRA. + gra_seb1 = osut.grossRoofArea(model.getSpaces()) + gra_seb2 = osut.grossRoofArea(entry) + self.assertAlmostEqual(gra_seb1, gra_seb2, places=2) + self.assertAlmostEqual(gra_seb1, total, places=2) + + sky_area = srr * total + + # Before adding skylight wells. + if version >= 350: + for sp in [plenum, entry, office, open, utility]: + self.assertTrue(sp.isEnclosedVolume()) + self.assertTrue(sp.isVolumeDefaulted()) + self.assertTrue(sp.isVolumeAutocalculated()) + self.assertGreater(sp.volume(), 0) + + zn = sp.thermalZone() + self.assertTrue(zn) + zn = zn.get() + self.assertTrue(zn.isVolumeDefaulted()) + self.assertTrue(zn.isVolumeAutocalculated()) + self.assertFalse(zn.volume()) + + # The method returns the GRA, calculated BEFORE adding skylights/wells. + rm2 = osut.addSkyLights(model.getSpaces(), dict(area=sky_area)) + if o.logs(): print(o.logs()) + self.assertAlmostEqual(rm2, total, places=2) + + entry_skies = osut.facets(entry, "Outdoors", "Skylight") + office_skies = osut.facets(office, "Outdoors", "Skylight") + utility_skies = osut.facets(utility, "Outdoors", "Skylight") + open_skies = osut.facets(open, "Outdoors", "Skylight") + + self.assertFalse(entry_skies) + self.assertFalse(office_skies) + self.assertFalse(utility_skies) + self.assertEqual(len(open_skies), 1) + open_sky = open_skies[0] + + skm2 = open_sky.grossArea() + self.assertAlmostEqual(skm2 / rm2, srr, places=2) + + # Assign construction to new skylights. + construction = osut.genConstruction(model, dict(type="skylight", uo=2.8)) + self.assertTrue(open_sky.setConstruction(construction)) + + # No change after adding skylight wells. + if version >= 350: + for sp in [plenum, entry, office, open, utility]: + self.assertTrue(sp.isEnclosedVolume()) + self.assertTrue(sp.isVolumeDefaulted()) + self.assertTrue(sp.isVolumeAutocalculated()) + self.assertGreater(sp.volume(), 0) + + zn = sp.thermalZone() + self.assertTrue(zn) + zn = zn.get() + self.assertTrue(zn.isVolumeDefaulted()) + self.assertTrue(zn.isVolumeAutocalculated()) + self.assertFalse(zn.volume()) + + model.save("./tests/files/osms/out/seb_sky.osm", True) + + self.assertEqual(o.status(), 0) + del model + def test35_facet_retrieval(self): o = osut.oslg self.assertEqual(o.status(), 0) From 62ec2712eebbd8327f1887aefb0db6b8604150b4 Mon Sep 17 00:00:00 2001 From: brgix Date: Sun, 3 Aug 2025 12:10:26 -0400 Subject: [PATCH 48/49] Completes 'Warehouse' addSkylights unittest --- src/osut/osut.py | 38 ++++++++++++++++-------------- tests/test_osut.py | 58 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 18 deletions(-) diff --git a/src/osut/osut.py b/src/osut/osut.py index 97c7ce8..d586c15 100644 --- a/src/osut/osut.py +++ b/src/osut/osut.py @@ -7637,9 +7637,9 @@ def addSkyLights(spaces=[], opts=dict) -> float: sset["roof" ] = roof sset["space" ] = space sset["m" ] = space.multiplier() - sset["sidelit"] = rooms[space]["sidelit"] + sset["sidelit"] = rooms[ide]["sidelit"] sset["sloped" ] = isSloped(roof) - sset["t0" ] = rooms[space]["t0"] + sset["t0" ] = rooms[ide]["t0"] sset["t" ] = openstudio.Transformation.alignFace(vtx) ssets.append(sset) @@ -7991,11 +7991,11 @@ def addSkyLights(spaces=[], opts=dict) -> float: if round(ly, 2) < round(sp, 2): continue if well: - cols = int(round(width / (wxl + sp)), 2) - rows = int(round(depth / (wyl + sp)), 2) + cols = int(round(width / (wxl + sp), 2)) + rows = int(round(depth / (wyl + sp), 2)) else: - cols = int(round(width / (wx + sp)), 2) - rows = int(round(depth / (wy + sp)), 2) + cols = int(round(width / (wx + sp), 2)) + rows = int(round(depth / (wy + sp), 2)) if cols < 2: continue if rows < 2: continue @@ -8303,8 +8303,9 @@ def addSkyLights(spaces=[], opts=dict) -> float: # Favour (large) arrays if meeting residual target, unless constrained. if "array" in fpm2: - if round(fpm2["array"]["m2"], 2) >= round(dm2, 2): - if not fpm2["tight"]: pattern = "array" + if dm2 < fpm2["array"]["m2"]: + if "tight" not in fpm2["array"] or fpm2["array"]["tight"] is False: + pattern = "array" if not pattern: fpm2 = dict(sorted(fpm2.items(), key=lambda f2: f2[1]["m2"])) @@ -8684,16 +8685,17 @@ def addSkyLights(spaces=[], opts=dict) -> float: dY = st["dY"] for j in range(st["rows"]): - sub = {} - sub["type" ] = "Skylight" - sub["count" ] = st["cols"] - sub["width" ] = w1 - sub["height" ] = d1 - sub["id" ] = "%s:%d:%d" % (roof.nameString(), i, j) - sub["sill" ] = dY + j * (2 * dY + d1) - if st["dX"]: sub["r_buffer"] = st["dX"] - if st["dX"]: sub["l_buffer"] = st["dX"] - if frame: sub["frame" ] = frame + sub = {} + sub["type" ] = "Skylight" + sub["count" ] = st["cols"] + sub["width" ] = w1 + sub["height"] = d1 + sub["id" ] = "%s:%d:%d" % (roof.nameString(), i, j) + sub["sill" ] = dY + j * (2 * dY + d1) + + if "dX" in st and st["dX"]: sub["r_buffer"] = st["dX"] + if "dX" in st and st["dX"]: sub["l_buffer"] = st["dX"] + if frame: sub["frame"] = frame addSubs(roof, sub, False, True, True) diff --git a/tests/test_osut.py b/tests/test_osut.py index 4511924..76d1378 100644 --- a/tests/test_osut.py +++ b/tests/test_osut.py @@ -5283,6 +5283,64 @@ def test34_generated_skylight_wells(self): self.assertEqual(o.status(), 0) del model + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + path = openstudio.path("./tests/files/osms/in/warehouse.osm") + model = translator.loadModel(path) + self.assertTrue(model) + model = model.get() + + for space in model.getSpaces(): + ide = space.nameString() + if not space.partofTotalFloorArea(): continue + + sidelit = osut.isDaylit(space, True, False) + toplit = osut.isDaylit(space, False) + if "Office" in ide: self.assertTrue(sidelit) + if "Storage" in ide: self.assertFalse(sidelit) + if "Office" in ide: self.assertFalse(toplit) + if "Storage" in ide: self.assertTrue(toplit) + + bulk = model.getSpaceByName("Zone3 Bulk Storage") + fine = model.getSpaceByName("Zone2 Fine Storage") + self.assertTrue(bulk) + self.assertTrue(fine) + bulk = bulk.get() + fine = fine.get() + + # No overhangs/attics. Calculation of roof area for SRR% is more intuitive. + gra_bulk = osut.grossRoofArea(bulk) + gra_fine = osut.grossRoofArea(fine) + + bulk_roof_m2 = sum([ruf.grossArea() for ruf in osut.roofs(bulk)]) + fine_roof_m2 = sum([ruf.grossArea() for ruf in osut.roofs(fine)]) + self.assertAlmostEqual(gra_bulk, bulk_roof_m2, places=2) + self.assertAlmostEqual(gra_fine, fine_roof_m2, places=2) + + # Initial SSR%. + bulk_skies = osut.facets(bulk, "Outdoors", "Skylight") + sky_area1 = sum([sk.grossArea() for sk in bulk_skies]) + ratio1 = sky_area1 / bulk_roof_m2 + self.assertAlmostEqual(sky_area1, 47.57, places=2) + self.assertAlmostEqual(ratio1, 0.01, places=2) + + srr = 0.04 + opts = {} + opts["srr" ] = srr + opts["size" ] = 2.4 + opts["clear"] = True + rm2 = osut.addSkyLights(bulk, opts) + + bulk_skies = osut.facets(bulk, "Outdoors", "Skylight") + sky_area2 = sum([sk.grossArea() for sk in bulk_skies]) + self.assertAlmostEqual(sky_area2, 128.19, places=2) + ratio2 = sky_area2 / rm2 + self.assertAlmostEqual(ratio2, srr, places=2) + + model.save("./tests/files/osms/out/warehouse_sky.osm", True) + + self.assertEqual(o.status(), 0) + del model + def test35_facet_retrieval(self): o = osut.oslg self.assertEqual(o.status(), 0) From 3a1e4f72533fcb7e2c18fe1ddb3f0700a295ad83 Mon Sep 17 00:00:00 2001 From: brgix Date: Sun, 3 Aug 2025 16:26:53 -0400 Subject: [PATCH 49/49] Pre-merge cleanup (e.g. resequencing functions) --- src/osut/osut.py | 110 +++++++++++++++++++++++------------------------ 1 file changed, 55 insertions(+), 55 deletions(-) diff --git a/src/osut/osut.py b/src/osut/osut.py index d586c15..6742b0b 100644 --- a/src/osut/osut.py +++ b/src/osut/osut.py @@ -6118,6 +6118,61 @@ def roofs(spaces = []) -> list: return rufs +def isDaylit(space=None, sidelit=True, toplit=True, baselit=True) -> bool: + """Validates whether space has outdoor-facing surfaces with fenestration. + + Args: + space (openstudio.model.Space): + An OpenStudio space. + sidelit (bool): + Whether to check for 'sidelighting', e.g. windows. + toplit (bool): + Whether to check for 'toplighting', e.g. skylights. + baselit (bool): + Whether to check for 'baselighting', e.g. glazed floors. + + Returns: + bool: Whether space is daylit. + False: If invalid inputs (see logs). + + """ + mth = "osut.isDaylit" + cl = openstudio.model.Space + walls = [] + rufs = [] + floors = [] + + if not isinstance(space, openstudio.model.Space): + return oslg.mismatch("space", space, cl, mth, CN.DBG, False) + + try: + sidelit = bool(sidelit) + except: + return oslg.invalid("sidelit", mth, 2, CN.DBG, False) + + try: + toplit = bool(toplit) + except: + return oslg.invalid("toplit", mth, 2, CN.DBG, False) + + try: + baselit = bool(baselit) + except: + return oslg.invalid("baselit", mth, 2, CN.DBG, False) + + if sidelit: walls = facets(space, "Outdoors", "Wall") + if toplit: rufs = facets(space, "Outdoors", "RoofCeiling") + if baselit: floors = facets(space, "Outdoors", "Floor") + + for surface in (walls + rufs + floors): + for sub in surface.subSurfaces(): + # All fenestrated subsurface types are considered, as user can set + # these explicitly (e.g. skylight in a wall) in OpenStudio. + if isFenestrated(sub): return True + + return False + + def addSubs(s=None, subs=[], clear=False, bound=False, realign=False, bfr=0.005) -> bool: """Adds sub surface(s) (e.g. windows, doors, skylights) to a surface. @@ -8700,58 +8755,3 @@ def addSkyLights(spaces=[], opts=dict) -> float: addSubs(roof, sub, False, True, True) return rm2 - - -def isDaylit(space=None, sidelit=True, toplit=True, baselit=True) -> bool: - """Validates whether space has outdoor-facing surfaces with fenestration. - - Args: - space (openstudio.model.Space): - An OpenStudio space. - sidelit (bool): - Whether to check for 'sidelighting', e.g. windows. - toplit (bool): - Whether to check for 'toplighting', e.g. skylights. - baselit (bool): - Whether to check for 'baselighting', e.g. glazed floors. - - Returns: - bool: Whether space is daylit. - False: If invalid inputs (see logs). - - """ - mth = "osut.isDaylit" - cl = openstudio.model.Space - walls = [] - rufs = [] - floors = [] - - if not isinstance(space, openstudio.model.Space): - return oslg.mismatch("space", space, cl, mth, CN.DBG, False) - - try: - sidelit = bool(sidelit) - except: - return oslg.invalid("sidelit", mth, 2, CN.DBG, False) - - try: - toplit = bool(toplit) - except: - return oslg.invalid("toplit", mth, 2, CN.DBG, False) - - try: - baselit = bool(baselit) - except: - return oslg.invalid("baselit", mth, 2, CN.DBG, False) - - if sidelit: walls = facets(space, "Outdoors", "Wall") - if toplit: rufs = facets(space, "Outdoors", "RoofCeiling") - if baselit: floors = facets(space, "Outdoors", "Floor") - - for surface in (walls + rufs + floors): - for sub in surface.subSurfaces(): - # All fenestrated subsurface types are considered, as user can set - # these explicitly (e.g. skylight in a wall) in OpenStudio. - if isFenestrated(sub): return True - - return False