diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 2c2b78b..ebeeaeb 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -5,7 +5,7 @@ on: branches: develop jobs: - test_oslg_ubuntu_latest: + test_osut_ubuntu_latest_openstudio_latest: runs-on: ubuntu-latest strategy: matrix: @@ -19,24 +19,6 @@ jobs: with: python-version: ${{ matrix.python-version }} - name: Install dependencies - run: python -m pip install --upgrade pip setuptools wheel openstudio oslg - - name: Run unit tests - run: python -m unittest - - test_oslg_ubuntu_2204: - runs-on: ubuntu-22.04 - strategy: - matrix: - python-version: ["3.10", "3.x"] - - steps: - - name: Check out repository - uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v5 - 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 oslg openstudio - name: Run unit tests run: python -m unittest diff --git a/README.md b/README.md index 1145bab..7d7249d 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,13 @@ Python implementation of the _OSut_ Ruby gem for the OpenStudio SDK. - Ruby GitHub [repository](https://github.com/rd2/osut) ---- + +_OSut_ interacts with _OpenStudio_: + +`pip install openstudio` + +---- + To download the _OSut_ Python package: `pip install --upgrade osut` diff --git a/pyproject.toml b/pyproject.toml index a5bd941..dd69ce3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,12 +1,12 @@ [project] name = "osut" -version = "0.6.0" +version = "0.7.0" description = "OpenStudio SDK utilities for Python" readme = "README.md" requires-python = ">=3.2" authors = [ {name = "Denis Bourgeois", email = "denis@rd2.ca"} ] maintainers = [ {name = "Denis Bourgeois", email = "denis@rd2.ca"} ] -dependencies = ["oslg","openstudio>=3.6.1"] +dependencies = ["oslg"] license = "BSD-3-Clause" license-files = ["LICENSE"] classifiers = [ diff --git a/src/osut/osut.py b/src/osut/osut.py index deaa4fb..36db366 100644 --- a/src/osut/osut.py +++ b/src/osut/osut.py @@ -107,8 +107,8 @@ class _CN: # Default (~1980s) envelope Uo (W/m2•K), based on surface type. _uo = dict( - shading = None, # N/A - partition = None, # N/A + shading = None, # N/A + partition = None, # N/A wall = 0.384, # rated R14.8 hr•ft2F/Btu roof = 0.327, # rated R17.6 hr•ft2F/Btu floor = 0.317, # rated R17.9 hr•ft2F/Btu (exposed floor) @@ -335,9 +335,8 @@ def genConstruction(model=None, specs=dict()): ide = "OSut.CON." + specs["type"] if specs["type"] not in uo(): return oslg.invalid("surface type", mth, 2, CN.ERR) - if "uo" not in specs: - specs["uo"] = uo()[ specs["type"] ] + if "uo" not in specs: specs["uo"] = uo()[ specs["type"] ] # can be None u = specs["uo"] if u: @@ -348,6 +347,8 @@ def genConstruction(model=None, specs=dict()): if u < 0: return oslg.negative(id + " Uo", mth, CN.ERR) + if round(u, 2) == 0: + return oslg.zero(id + " Uo", mth, CN.ERR) if u > 5.678: return oslg.invalid(id + " Uo (> 5.678)", mth, 2, CN.ERR) @@ -581,7 +582,7 @@ def genConstruction(model=None, specs=dict()): a["compo" ]["id" ] = "OSut." + mt + ".%03d" % int(d * 1000) elif specs["type"] == "window": - a["glazing"]["u" ] = specs["uo"] + a["glazing"]["u" ] = u if u else uo()["window"] a["glazing"]["shgc"] = 0.450 if "shgc" in specs: a["glazing"]["shgc"] = specs["shgc"] a["glazing"]["id" ] = "OSut.window" @@ -589,7 +590,7 @@ def genConstruction(model=None, specs=dict()): a["glazing"]["id" ] += ".SHGC%d" % (a["glazing"]["shgc"]*100) elif specs["type"] == "skylight": - a["glazing"]["u" ] = specs["uo"] + a["glazing"]["u" ] = u if u else uo()["skylight"] a["glazing"]["shgc"] = 0.450 if "shgc" in specs: a["glazing"]["shgc"] = specs["shgc"] a["glazing"]["id" ] = "OSut.skylight" @@ -599,14 +600,14 @@ def genConstruction(model=None, specs=dict()): if a["glazing"]: layers = openstudio.model.FenestrationMaterialVector() - u = a["glazing"]["u" ] + u0 = a["glazing"]["u" ] shgc = a["glazing"]["shgc"] lyr = model.getSimpleGlazingByName(a["glazing"]["id"]) if lyr: lyr = lyr.get() else: - lyr = openstudio.model.SimpleGlazing(model, u, shgc) + lyr = openstudio.model.SimpleGlazing(model, u0, shgc) lyr.setName(a["glazing"]["id"]) layers.append(lyr) @@ -635,49 +636,54 @@ def genConstruction(model=None, specs=dict()): layers.append(lyr) - c = openstudio.model.Construction(layers) + c = openstudio.model.Construction(layers) c.setName(ide) # Adjust insulating layer thickness or conductivity to match requested Uo. - if not a["glazing"]: - ro = 1 / specs["uo"] - film()[specs["type"]] if specs["uo"] else 0 + if u and not a["glazing"]: + ro = 1 / u - flm - if specs["type"] == "door": # 1x layer, adjust conductivity - layer = c.getLayer(0).to_StandardOpaqueMaterial() + if ro > 0: + if specs["type"] == "door": # 1x layer, adjust conductivity + layer = c.getLayer(0).to_StandardOpaqueMaterial() - if not layer: - return oslg.invalid(id + " standard material?", mth, 0) + if not layer: + return oslg.invalid(id + " standard material?", mth, 0) - layer = layer.get() - k = layer.thickness() / ro - layer.setConductivity(k) + layer = layer.get() + k = layer.thickness() / ro + layer.setConductivity(k) - elif ro > 0: # multiple layers, adjust insulating layer thickness - lyr = insulatingLayer(c) + else: # multiple layers, adjust insulating layer thickness + lyr = insulatingLayer(c) - if not lyr["index"] or not lyr["type"] or not lyr["r"]: - return oslg.invalid(id + " construction", mth, 0) + if not lyr["index"] or not lyr["type"] or not lyr["r"]: + return oslg.invalid(id + " construction", mth, 0) - index = lyr["index"] - layer = c.getLayer(index).to_StandardOpaqueMaterial() + index = lyr["index"] + layer = c.getLayer(index).to_StandardOpaqueMaterial() - if not layer: - return oslg.invalid(id + " material %d" % index, mth, 0) + if not layer: + return oslg.invalid(id + " material %d" % index, mth, 0) - layer = layer.get() - k = layer.conductivity() - d = (ro - rsi(c) + lyr["r"]) * k + layer = layer.get() + k = layer.conductivity() + d = (ro - rsi(c) + lyr["r"]) * k - if d < 0.03: - return oslg.invalid(id + " adjusted material thickness", mth, 0) + if d < 0.03: + m = id + " adjusted material thickness" + return oslg.invalid(m, mth, 0) - nom = re.sub(r'[^a-zA-Z]', '', layer.nameString()) - nom = re.sub(r'OSut', '', nom) - nom = "OSut." + nom + ".%03d" % int(d * 1000) + nom = re.sub(r'[^a-zA-Z]', '', layer.nameString()) + nom = re.sub(r'OSut', '', nom) + nom = "OSut." + nom + ".%03d" % int(d * 1000) - if not model.getStandardOpaqueMaterialByName(nom): - layer.setName(nom) - layer.setThickness(d) + if model.getStandardOpaqueMaterialByName(nom): + omat = model.getStandardOpaqueMaterialByName(nom).get() + c.setLayer(index, omat) + else: + layer.setName(nom) + layer.setThickness(d) return c @@ -1650,7 +1656,7 @@ def scheduleIntervalMinMax(sched=None) -> dict: - "min" (float): min temperature. (None if invalid inputs - see logs). - "max" (float): max temperature. (None if invalid inputs - see logs). """ - mth = "osut.scheduleCompactMinMax" + mth = "osut.scheduleIntervalMinMax" cl = openstudio.model.ScheduleInterval vals = [] res = dict(min=None, max=None) @@ -1658,10 +1664,19 @@ def scheduleIntervalMinMax(sched=None) -> dict: if not isinstance(sched, cl): return oslg.mismatch("sched", sched, cl, mth, CN.DBG, res) - vals = sched.timeSeries().values() + values = sched.timeSeries().values() - res["min"] = min(values) - res["max"] = max(values) + for i in range(len(values)): + try: + value = float(values[i]) + vals.append(value) + except: + oslg.invalid("numerical at %d" % i, mth, 1, CN.ERR) + + if not vals: return res + + res["min"] = min(vals) + res["max"] = max(vals) try: res["min"] = float(res["min"]) @@ -2595,6 +2610,17 @@ def availabilitySchedule(model=None, avl=""): return schedule +# ---- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- ---- # +# This final set of utilities targets OpenStudio geometry. Many of the +# following geometry methods rely on Boost as an OpenStudio dependency. +# As per Boost requirements, points (e.g. vertical polygon) must be 'aligned': +# - first rotated/tilted as to lay flat along XY plane (Z-axis ~= 0) +# - initial Z-axis values now become Y-axis values +# - points with the lowest X-axis values are 'aligned' along X-axis (0) +# - points with the lowest Z-axis values are 'aligned' along Y-axis (0) +# - for several Boost methods, points must be clockwise in sequence +# +# Check OSut's poly() method, which offers such Boost-related options. def transforms(group=None) -> dict: """"Returns OpenStudio site/space transformation & rotation angle. @@ -2698,7 +2724,7 @@ def p3Dv(pts=None) -> openstudio.Point3dVector: pts (list): OpenStudio 3D points. Returns: - openstudio.Point3dVector: Vector of 3D points (see logs if empty). + openstudio.Point3dVector: Vector of 3D points (see 'p3Dv' logs if empty). """ mth = "osut.p3Dv" @@ -2711,7 +2737,10 @@ def p3Dv(pts=None) -> openstudio.Point3dVector: elif isinstance(pts, openstudio.Point3dVector): return pts elif isinstance(pts, openstudio.model.PlanarSurface): - pts = list(pts.vertices()) + for vt in pts.vertices(): + pt = openstudio.Point3d(vt.x(), vt.y(), vt.z()) + v.append(pt) + return v try: pts = list(pts) @@ -3030,7 +3059,7 @@ def nextUp(pts=None, pt=None): None: If invalid inputs (see logs). """ - mth = "osut.nextUP" + mth = "osut.nextUp" pts = p3Dv(pts) cl = openstudio.Point3d @@ -3168,7 +3197,7 @@ def uniques(pts=None, n=0) -> openstudio.Point3dVector: Requested number of unique points (0 returns all). Returns: - openstudio.Point3dVector: Unique points (see logs if empty). + openstudio.Point3dVector: Unique points (see logs). """ mth = "osut.uniques" @@ -3179,7 +3208,8 @@ def uniques(pts=None, n=0) -> openstudio.Point3dVector: try: n = int(n) except: - return oslg.mismatch("n unique points", n, int, mth, CN.DBG, v) + oslg.mismatch("n points", n, int, mth, CN.DBG) + n = 0 for pt in pts: if not holds(v, pt): v.append(pt) @@ -3314,7 +3344,7 @@ def isPointAlongSegment(p0=None, sg=[]) -> bool: Returns: bool: Whether a 3D point lies ~along a 3D point segment. - False: If invalid inputs. + False: If invalid inputs (see logs). """ mth = "osut.isPointAlongSegment" @@ -3323,12 +3353,10 @@ def isPointAlongSegment(p0=None, sg=[]) -> bool: 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 not isSegment(sg): return False if holds(sg, p0): return True - a = sg[0] + a = sg[ 0] b = sg[-1] ab = b - a abn = b - a @@ -3370,7 +3398,7 @@ def isPointAlongSegments(p0=None, sgs=[]) -> bool: 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) + return oslg.mismatch("point", p0, cl1, mth, CN.DBG, False) for sg in sgs: if isPointAlongSegment(p0, sg): return True @@ -3444,14 +3472,6 @@ 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 @@ -3632,7 +3652,7 @@ def blc(pts=None) -> openstudio.Point3dVector: def nonCollinears(pts=None, n=0) -> openstudio.Point3dVector: - """Returns sequential non-collinear points in an OpenStudio 3D point vector. + """Returns non-collinear points in an OpenStudio 3D point vector. Args: pts (openstudio.Point3dVector): @@ -3641,11 +3661,10 @@ def nonCollinears(pts=None, n=0) -> openstudio.Point3dVector: Requested number of non-collinears (0 returns all). Returns: - openstudio.Point3dVector: non-collinears (see logs if empty). + openstudio.Point3dVector: non-collinears (see logs). """ mth = "osut.nonCollinears" - v = openstudio.Point3dVector() a = [] pts = uniques(pts) if len(pts) < 3: return pts @@ -3653,12 +3672,8 @@ def nonCollinears(pts=None, n=0) -> openstudio.Point3dVector: 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) + oslg.mismatch("n points", n, int, mth, CN.DBG) + n = 0 # Evaluate cross product of vectors of 3x sequential points. for i2, p2 in enumerate(pts): @@ -3680,9 +3695,7 @@ def nonCollinears(pts=None, n=0) -> openstudio.Point3dVector: a.rotate(1) a = list(a) - if n > len(a): return p3Dv(a) - if n < 0 and abs(n) > len(a): return p3Dv(a) - + if abs(n) > len(a): n = 0 if n > 0: a = a[0:n] if n < 0: a = a[n:] @@ -3691,7 +3704,7 @@ def nonCollinears(pts=None, n=0) -> openstudio.Point3dVector: def collinears(pts=None, n=0) -> openstudio.Point3dVector: """ - Returns sequential collinear points in an OpenStudio 3D point vector. + Returns collinear points in an OpenStudio 3D point vector. Args: pts (openstudio.Point3dVector): @@ -3700,38 +3713,31 @@ def collinears(pts=None, n=0) -> openstudio.Point3dVector: Requested number of collinears (0 returns all). Returns: - openstudio.Point3dVector: collinears (see logs if empty). + openstudio.Point3dVector: collinears (see logs). """ mth = "osut.collinears" - v = openstudio.Point3dVector() - a = [] + a = openstudio.Point3dVector() 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) + oslg.mismatch("n points", n, int, mth, CN.DBG) + n = 0 ncolls = nonCollinears(pts) - if not ncolls: return pts + if not ncolls: return a for pt in pts: if pt not in ncolls: a.append(pt) - if n > len(a): return p3Dv(a) - if n < 0 and abs(n) > len(a): return p3Dv(a) - + if abs(n) > len(a): n = 0 if n > 0: a = a[0:n] if n < 0: a = a[n:] - return p3Dv(a) + return a def poly(pts=None, vx=False, uq=False, co=False, tt=False, sq="no") -> openstudio.Point3dVector: @@ -5237,15 +5243,12 @@ def spaceHeight(space=None) -> float: (float): Full height of space (0.0 if invalid input). """ - if not isinstance(space, openstudio.model.Space): - return 0 + hght = 0 + 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(): zs = [pt.z() for pt in surface.vertices()] minZ = min(minZ, min(zs)) @@ -5288,21 +5291,18 @@ def spaceWidth(space=None) -> float: # - retain only other floor surfaces sharing same 3D plane # - recover potential union between floor surfaces # - fall back to largest floor surface if invalid union + # - return width of largest bounded box 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 + if not polyg: return 0 polyg.reverse() - polyg = p3Dv(polyg) if len(floors) > 1: - floors = [flr for flr in floors if plane.equal(fl.plane(), 0.001)] + floors = [flr for flr in floors if plane.equal(flr.plane(), 0.001)] if len(floors) > 1: polygs = [poly(flr, False, True, True, t, "ulc") for flr in floors] @@ -5315,12 +5315,16 @@ def spaceWidth(space=None) -> float: union = openstudio.joinAll(polygs, 0.01)[0] polyg = poly(union, False, True, True) + if not polyg: return 0 + + polyg = list(polyg) + polyg.reverse() - box = boundedBox(polyg) - oslg.clean() + res = realignedFace(polyg) + if not res["box"]: return 0 # A bounded box's 'height', at its narrowest, is its 'width'. - return height(box) + return height(res["box"]) def genAnchors(s=None, sset=[], tag="box") -> int: @@ -6079,7 +6083,7 @@ def genSlab(pltz=[], z=0) -> openstudio.Point3dVector: slb = vtx # Once joined, re-adjust Z-axis coordinates. - if abs(z) > CN.TOL: + if round(z, 2) != 0.00: vtx = openstudio.Point3dVector() for pt in slb: vtx.append(openstudio.Point3d(pt.x(), pt.y(), z)) diff --git a/tests/test_osut.py b/tests/test_osut.py index 76d1378..395ed54 100644 --- a/tests/test_osut.py +++ b/tests/test_osut.py @@ -392,7 +392,7 @@ def test05_construction_generation(self): self.assertEqual(o.status(), 0) del model - # Insulated (conditioned), parking garage roof (polyiso under 8" slab). + # Roof above conditioned parking garage (polyiso under 8" slab). specs = dict(type="roof", uo=0.214, clad="heavy", frame="medium", finish="none") model = openstudio.model.Model() c = osut.genConstruction(model, specs) @@ -649,6 +649,41 @@ def test05_construction_generation(self): self.assertEqual(o.status(), 0) del model + # Invalid Uo (here, skylights and windows inherit default Uo values) + specs = dict(type="skylight", uo=None) + model = openstudio.model.Model() + c = osut.genConstruction(model, specs) + self.assertEqual(o.status(), 0) + self.assertFalse(o.logs()) + self.assertTrue(c) + self.assertTrue(isinstance(c, openstudio.model.Construction)) + self.assertEqual(c.nameString(), "OSut.CON.skylight") + self.assertTrue(c.layers()) + self.assertEqual(len(c.layers()), 1) + self.assertEqual(c.layers()[0].nameString(), "OSut.skylight.U3.5.SHGC45") + r = osut.rsi(c) + self.assertAlmostEqual(r, 1/osut.uo()["skylight"], places=3) + self.assertFalse(o.logs()) + self.assertEqual(o.status(), 0) + del model + + # Invalid Uo (here, Uo-adjustments are ignored altogether) + specs = dict(type="wall", uo=None) + model = openstudio.model.Model() + c = osut.genConstruction(model, specs) + self.assertEqual(o.status(), 0) + self.assertFalse(o.logs()) + self.assertTrue(c) + self.assertTrue(isinstance(c, openstudio.model.Construction)) + self.assertEqual(c.nameString(), "OSut.CON.wall") + self.assertTrue(c.layers()) + self.assertEqual(len(c.layers()), 4) + r = osut.rsi(c) + self.assertAlmostEqual(1/r, 2.23, places=2) # not matching any defaults + self.assertFalse(o.logs()) + self.assertEqual(o.status(), 0) + del model + def test06_internal_mass(self): o = osut.oslg self.assertEqual(o.status(), 0) @@ -1696,9 +1731,27 @@ def test17_minmax_heatcool_setpoints(self): self.assertTrue(cc.setTemperatureCalculationRequestedAfterLayerNumber(1)) self.assertTrue(floor.setConstruction(cc)) + # Test 'fixed interval' schedule. Annual time series - no variation. + start = model.getYearDescription().makeDate(1, 1) + inter = openstudio.Time(0, 1, 0, 0) + values = openstudio.createVector([22.78] * 8760) + series = openstudio.TimeSeries(start, inter, values, "") + limits = openstudio.model.ScheduleTypeLimits(model) + limits.setName("Radiant Electric Heating Setpoint Schedule Type Limits") + self.assertTrue(limits.setNumericType("Continuous")) + self.assertTrue(limits.setUnitType("Temperature")) + + schedule = openstudio.model.ScheduleFixedInterval(model) + schedule.setName("Radiant Electric Heating Setpoint Schedule") + self.assertTrue(schedule.setTimeSeries(series)) + self.assertTrue(schedule.setTranslatetoScheduleFile(False)) + self.assertTrue(schedule.setScheduleTypeLimits(limits)) + + tvals = schedule.timeSeries().values() + self.assertTrue(isinstance(tvals, openstudio.Vector)) + for i in range(len(tvals)): self.assertTrue(isinstance(tvals[i], float)) + availability = osut.availabilitySchedule(model) - schedule = openstudio.model.ScheduleConstant(model) - self.assertTrue(schedule.setValue(22.78)) # reuse cooling setpoint # Create radiant electric heating. ht = (openstudio.model.ZoneHVACLowTemperatureRadiantElectric( @@ -3255,49 +3308,202 @@ def test25_segments_triads_orientation(self): 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)" + # Stress test 'to_p3Dv'. 4 valid input cases. + # Valid case #1: a single Point3d. + vtx = osut.p3Dv(p0) + self.assertTrue(isinstance(vtx, openstudio.Point3dVector)) + self.assertEqual(vtx[0], p0) # same object ID + + # Valid case #2: a Point3dVector. + vtxx = openstudio.Point3dVector() + vtxx.append(p0) + vtxx.append(p1) + vtxx.append(p2) + vtxx.append(p3) + vtx = osut.p3Dv(vtxx) + self.assertTrue(isinstance(vtx, openstudio.Point3dVector)) + self.assertEqual(vtx[ 0], p0) # same object ID + self.assertEqual(vtx[ 1], p1) # same object ID + self.assertEqual(vtx[ 2], p2) # same object ID + self.assertEqual(vtx[-1], p3) # same object ID + + # Valid case #3: Surface vertices. + model = openstudio.model.Model() + surface = openstudio.model.Surface(vtxx, model) + self.assertTrue(isinstance(surface.vertices(), tuple)) # ! Point3dVector + self.assertEqual(len(surface.vertices()), 4) + vtx = osut.p3Dv(vtxx) + self.assertTrue(isinstance(vtx, openstudio.Point3dVector)) + self.assertEqual(len(vtx), 4) + self.assertEqual(vtx[0], p0) + self.assertEqual(vtx[1], p1) + self.assertEqual(vtx[2], p2) + self.assertEqual(vtx[3], p3) + + # Valid case #4: Array. + vtx = osut.p3Dv([p0, p1, p2, p3]) + self.assertTrue(isinstance(vtx, openstudio.Point3dVector)) + self.assertEqual(len(vtx), 4) + self.assertEqual(vtx[0], p0) + self.assertEqual(vtx[1], p1) + self.assertEqual(vtx[2], p2) + self.assertEqual(vtx[3], p3) + + # Stress test 'nextUp'. + m0 = "Invalid 'points (2+)' arg #1 (osut.nextUp)" + + # Invalid case. + pt = osut.nextUp([], p0) + self.assertFalse(pt) + self.assertTrue(o.is_warn()) + self.assertEqual(len(o.logs()), 1) + self.assertEqual(o.logs()[0]["message"], m0) + self.assertEqual(o.clean(), DBG) + + # Valid case. + pt = osut.nextUp([p0, p1, p2, p3], p0) + self.assertTrue(isinstance(pt, openstudio.Point3d)) + self.assertEqual(pt, p1) + + pt = osut.nextUp([p0, p0, p0], p0) + self.assertTrue(isinstance(pt, openstudio.Point3d)) + self.assertEqual(pt, p0) + + # Stress test 'segments'. Invalid case. + sgs = osut.segments(p3) + self.assertTrue(isinstance(sgs, openstudio.Point3dVectorVector)) + self.assertFalse(sgs) + self.assertEqual(o.status(), 0) # nothing logged + + sgs = osut.segments([p3, p3]) + self.assertTrue(isinstance(sgs, openstudio.Point3dVectorVector)) + self.assertFalse(sgs) + self.assertEqual(o.status(), 0) # nothing logged + + # Valid case. + sgs = osut.segments([p0, p1, p2, p3]) + self.assertTrue(isinstance(sgs, openstudio.Point3dVectorVector)) + self.assertEqual(len(sgs), 4) + self.assertTrue(isinstance(sgs[-1], tuple)) # ! Point3dVector + + # Stress test 'uniques'. + m0 = "'n points' str? expecting int (osut.uniques)" + # Invalid case. + uniks = osut.uniques([p0, p1, p2, p3], "osut") + self.assertTrue(isinstance(uniks, openstudio.Point3dVector)) + self.assertEqual(len(uniks), 4) + self.assertTrue(o.is_debug()) + self.assertEqual(len(o.logs()), 1) + self.assertEqual(o.logs()[0]["message"], m0) + self.assertEqual(o.clean(), DBG) + + # Valid, basic case. + uniks = osut.uniques([p0, p1, p2, p3]) + self.assertTrue(isinstance(uniks, openstudio.Point3dVector)) + self.assertEqual(len(uniks), 4) + + uniks = osut.uniques([p0, p1, p2, p3], 0) + self.assertTrue(isinstance(uniks, openstudio.Point3dVector)) + self.assertEqual(len(uniks), 4) + + # Valid, first 3 points. + uniks = osut.uniques([p0, p1, p2, p3], 3) + self.assertTrue(isinstance(uniks, openstudio.Point3dVector)) + self.assertEqual(len(uniks), 3) + + # Valid, last 3 points. + uniks = osut.uniques([p0, p1, p2, p3], -3) + self.assertTrue(isinstance(uniks, openstudio.Point3dVector)) + self.assertEqual(len(uniks), 3) + + # Valid, n = 5: returns original 4 uniques points. + uniks = osut.uniques([p0, p1, p2, p3], 5) + self.assertTrue(isinstance(uniks, openstudio.Point3dVector)) + self.assertEqual(len(uniks), 4) + + # Valid, n = -5: returns original 4 uniques points. + uniks = osut.uniques([p0, p1, p2, p3], -5) + self.assertTrue(isinstance(uniks, openstudio.Point3dVector)) + self.assertEqual(len(uniks), 4) + + # Stress tests collinears. + m0 = "'n points' str? expecting int (osut.collinears)" + + # Invalid case - raise DEBUG message, yet returns valid collinears. + collinears = osut.collinears([p0, p1, p3, p8], "osut") + self.assertTrue(isinstance(collinears, openstudio.Point3dVector)) + self.assertEqual(len(collinears), 1) + self.assertTrue(osut.areSame(collinears[0], p0)) + self.assertTrue(o.is_debug()) + self.assertEqual(len(o.logs()), 1) + self.assertEqual(o.logs()[0]["message"], m0) + self.assertEqual(o.clean(), DBG) + + # Valid, basic case collinears = osut.collinears([p0, p1, p3, p8]) self.assertEqual(len(collinears), 1) self.assertTrue(osut.areSame(collinears[0], p0)) + collinears = osut.collinears([p0, p1, p3, p8], 0) + 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)) + self.assertTrue(osut.isPointAlongSegment(p0, sgs[0])) + # Only 2 collinears, so request for first 3 is ignored. 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)) + # First collinear (out of 2). collinears = osut.collinears([p0, p1, p2, p3, p8], 1) self.assertEqual(len(collinears), 1) self.assertTrue(osut.areSame(collinears[0], p0)) + # Last collinear (out of 2). collinears = osut.collinears([p0, p1, p2, p3, p8], -1) self.assertEqual(len(collinears), 1) self.assertTrue(osut.areSame(collinears[0], p1)) + # First two vs last two: same result. 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)) + # Ignore n request when abs(n) > number of actual collinears. 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.assertEqual(o.clean(), DBG) + 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(collinears), 2) + self.assertTrue(osut.areSame(collinears[0], p0)) + self.assertTrue(osut.areSame(collinears[1], p1)) + + # Stress test isPointAlongSegment. + m0 = "'point' str? expecting Point3d (osut.p3Dv)" + + # Invalid case. + self.assertFalse(osut.isPointAlongSegment(p3, "osut")) + self.assertTrue(o.is_debug()) self.assertEqual(len(o.logs()), 1) - self.assertEqual(o.logs()[0]["message"], m2) + self.assertEqual(o.logs()[0]["message"], m0) self.assertEqual(o.clean(), DBG) + # Valid case. + pts = openstudio.Point3dVector() + pts.append(p0) + pts.append(p1) + self.assertFalse(osut.isPointAlongSegment(p3, pts)) + # 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]) @@ -5349,15 +5555,15 @@ def test35_facet_retrieval(self): translator = openstudio.osversion.VersionTranslator() - path = openstudio.path("./tests/files/osms/out/seb2.osm") + path = openstudio.path("./tests/files/osms/out/seb_ext2.osm") model = translator.loadModel(path) self.assertTrue(model) model = model.get() spaces = model.getSpaces() surfs = model.getSurfaces() subs = model.getSubSurfaces() - self.assertEqual(len(surfs), 56) - self.assertEqual(len(subs), 8) + self.assertEqual(len(surfs), 59) + self.assertEqual(len(subs), 14) # The solution is similar to: # OpenStudio::Model::Space::findSurfaces(minDegreesFromNorth, @@ -5381,15 +5587,15 @@ def test35_facet_retrieval(self): roofs1 = osut.facets(spaces, "Outdoors", "RoofCeiling", "top") roofs2 = osut.facets(spaces, "Outdoors", "RoofCeiling", "foo") - self.assertEqual(len(windows), 8) - self.assertEqual(len(skylights), 0) - self.assertEqual(len(walls), 26) + self.assertEqual(len(windows), 11) + self.assertEqual(len(skylights), 3) + self.assertEqual(len(walls), 28) self.assertFalse(northsouth) self.assertEqual(len(northeast), 8) self.assertEqual(len(north), 14) self.assertEqual(len(floors1a), 4) self.assertEqual(len(floors1b), 4) - self.assertEqual(len(roofs1), 4) + self.assertEqual(len(roofs1), 5) self.assertFalse(roofs2) # Concise variants, same output. In the SEB model, floors face "Ground". @@ -5574,7 +5780,7 @@ def test36_slab_generation(self): self.assertEqual(len(surface.vertices()), 12) self.assertAlmostEqual(surface.grossArea(), 5 * 20 - 1, places=2) - # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # # Same as previous, yet overlapping 'plate' has both negative dX & dY, # while XY origin is set at top-right (not bottom-left) corner. # ____ ____ @@ -5602,6 +5808,17 @@ def test36_slab_generation(self): self.assertEqual(o.status(), 0) del model + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + # Invalid input case. + plates = ["osut"] + slab = osut.genSlab(plates, z0) + self.assertTrue(o.is_debug()) + self.assertEqual(len(o.logs()), 1) + self.assertTrue("str? expecting dict" in o.logs()[0]["message"]) + self.assertTrue(isinstance(slab, openstudio.Point3dVector)) + self.assertFalse(slab) + self.assertEqual(o.clean(), DBG) + def test37_roller_shades(self): o = osut.oslg self.assertEqual(o.status(), 0) @@ -5673,5 +5890,90 @@ def test37_roller_shades(self): del model self.assertEqual(o.status(), 0) + def test38_space_height_width(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/in/warehouse.osm") + model = translator.loadModel(path) + self.assertTrue(model) + model = model.get() + + fine = model.getSpaceByName("Zone2 Fine Storage") + self.assertTrue(fine) + fine = fine.get() + + # The Fine Storage space has 2 floors, at different Z-axis levels: + # - main ground floor (slab on grade), Z=0.00m + # - mezzanine floor, adjacent to the office space ceiling below, Z=4.27m + self.assertTrue(len(osut.facets(fine, "all", "floor")), 2) + groundfloor = model.getSurfaceByName("Fine Storage Floor") + mezzanine = model.getSurfaceByName("Office Roof Reversed") + self.assertTrue(groundfloor) + self.assertTrue(mezzanine) + groundfloor = groundfloor.get() + mezzanine = mezzanine.get() + + # The ground floor is L-shaped, floor surfaces have differenet Z=axis + # levels, etc. In the context of codes/standards like ASHRAE 90.1 or the + # Canadian NECB, determining what constitutes a space's 'height' and/or + # 'width' matters, namely with regards to geometry-based LPD rules + # (e.g. adjustments based on corridor 'width'). Not stating here what + # the definitive answers should be in all cases. There are however a few + # OSut functions that may be helpful. + # + # OSut's 'aligned' height and width functions were initially developed + # for non-flat surfaces, like walls and sloped roofs - particularly + # useful when such surfaces are rotated in 3D space. It's somewhat less + # intuitive when applied to horizontal surfaces like floors. In a + # nutshell, the functions lay out the surface in a 2D grid, aligning it + # along its 'bounded box'. It then determines a bounding box around the + # surface, once aligned: + # - 'aligned height' designates the narrowest edge of the bounding box + # - 'aligned width' designates the widest edge of the bounding box + # + # Useful? In some circumstances, maybe. One can argue that these may be + # of limited use for width-based LPD adjustment calculations. + self.assertAlmostEqual(osut.alignedHeight(groundfloor), 30.48, places=2) + self.assertAlmostEqual(osut.alignedWidth(groundfloor), 45.72, places=2) + self.assertAlmostEqual(osut.alignedHeight(mezzanine), 9.14, places=2) + self.assertAlmostEqual(osut.alignedWidth(mezzanine), 25.91, places=2) + + # OSut's 'spaceHeight' and 'spaceWidth' are more suitable for height- or + # width-based LPD adjustement calculations. OSut sets a space's width as + # the length of the narrowest edge of the largest bounded box that fits + # within a collection of neighbouring floor surfaces. This is considered + # reasonable for a long corridor, with varying widths along its full + # length (e.g. occasional alcoves). + # + # Achtung! The function can be time consuming (multiple iterations) for + # very convoluted spaces (e.g. long corridors with multiple concavities). + self.assertAlmostEqual(osut.spaceHeight(fine), 8.53, places=2) + self.assertAlmostEqual(osut.spaceWidth(fine), 21.33, places=2) + + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + path = openstudio.path("./tests/files/osms/out/seb_sky.osm") + model = translator.loadModel(path) + self.assertTrue(model) + model = model.get() + + openarea = model.getSpaceByName("Open area 1") + self.assertTrue(openarea) + openarea = openarea.get() + + floor = osut.facets(openarea, "all", "floor") + self.assertEqual(len(floor), 1) + floor = floor[0] + + self.assertAlmostEqual(osut.alignedHeight(floor), 6.88, places=2) + self.assertAlmostEqual(osut.alignedWidth(floor), 8.22, places=2) + self.assertAlmostEqual(osut.spaceHeight(openarea), 3.96, places=2) + self.assertAlmostEqual(osut.spaceWidth(openarea), 3.77, places=2) + + self.assertEqual(o.status(), 0) + if __name__ == "__main__": unittest.main()