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 diff --git a/pyproject.toml b/pyproject.toml index f01f4a3..e371945 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,14 +1,16 @@ [project] name = "osut" -version = "0.6.0a1" +version = "0.6.0a2" 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 = [ + "numpy", "oslg", "openstudio>=3.6.1", + "dataclasses", ] license = "BSD-3-Clause" license-files = ["LICENSE"] diff --git a/src/osut/osut.py b/src/osut/osut.py index 66b93c2..6742b0b 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). @@ -209,6 +212,51 @@ def uo() -> dict: return _uo +def each_cons(it, n): + """A proxy for Ruby enumerate'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/enumerate.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. @@ -238,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: @@ -545,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"]: @@ -623,65 +671,65 @@ 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.setLowerLimitValue(0) - onoff.setUpperLimitValue(1) - onoff.setNumericType("Discrete") - onoff.setUnitType("Availability") + onoff = openstudio.model.ScheduleTypeLimits(model) + 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.setScheduleTypeLimits(onoff) - sch.defaultDaySchedule.setName("OSut.SHADE.Ruleset.Default") + sch = openstudio.model.ScheduleRuleset(model, 0) + 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() 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(ide) + 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) + ide = "OSut.SHADE" + shd = model.getShadeByName(ide) if shd: shd = shd.get() else: - shd = openstudio.model.Shade(mdl) - shd.setName(id) + shd = openstudio.model.Shade(model) + 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 @@ -728,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) @@ -746,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) @@ -777,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. @@ -806,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): @@ -824,27 +872,27 @@ 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) - if not c: return False + if c is None: return False if type in t1: if type == "roofceiling": @@ -917,41 +965,41 @@ 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 -def are_standardOpaqueLayers(lc=None) -> bool: +def areStandardOpaqueLayers(lc=None) -> bool: """Validates if every material in a layered construction is standard/opaque. Args: @@ -963,7 +1011,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): @@ -993,7 +1041,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 @@ -1150,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: @@ -1162,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 @@ -1180,44 +1228,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(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: - s (openstudio.model.Surface): - An opaque surface. + surfaces (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(surfaces, cl): + surfaces = [surfaces] + else: + try: + surfaces = list(surfaces) + except: + return oslg.mismatch("surfaces", surfaces, list, mth, CN.DBG, False) - if not val: - return oslg.invalid("spandrel", mth, 1, CN.ERR, False) + for i, s in enumerate(surfaces): + 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: @@ -1229,7 +1282,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): @@ -1248,8 +1301,142 @@ def is_fenestration(s=None) -> bool: return True - -def has_airLoopsHVAC(model=None) -> bool: +# ---- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- ---- # +# ---- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- ---- # +# 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. Args: @@ -1260,7 +1447,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): @@ -1509,20 +1696,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(): @@ -1530,7 +1717,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(): @@ -1538,7 +1725,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(): @@ -1546,7 +1733,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 @@ -1571,13 +1758,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(): @@ -1585,7 +1772,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(): @@ -1593,7 +1780,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(): @@ -1601,7 +1788,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(): @@ -1612,23 +1799,23 @@ 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 -def has_heatingTemperatureSetpoints(model=None): - """Confirms if model has zones with valid heating temperature setpoints. +def hasHeatingTemperatureSetpoints(model=None): + """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.has_heatingTemperatureSetpoints" + mth = "osut.hasHeatingTemperatureSetpoints" cl = openstudio.model.Model if not isinstance(model, cl): @@ -1689,20 +1876,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(): @@ -1710,7 +1897,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(): @@ -1718,7 +1905,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(): @@ -1726,7 +1913,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 @@ -1752,13 +1939,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(): @@ -1766,7 +1953,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(): @@ -1774,7 +1961,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(): @@ -1782,7 +1969,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(): @@ -1793,24 +1980,24 @@ 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 -def has_coolingTemperatureSetpoints(model=None): - """Confirms if model has zones with valid cooling temperature setpoints. +def hasCoolingTemperatureSetpoints(model=None): + """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.has_coolingTemperatureSetpoints" + mth = "osut.hasCoolingTemperatureSetpoints" cl = openstudio.model.Model if not isinstance(model, cl): @@ -1822,15 +2009,15 @@ def has_coolingTemperatureSetpoints(model=None): return False -def is_vestibule(space=None): - """Validates whether space is a vestibule. +def areVestibules(spaces=None): + """Validates whether one or more spaces can be considered vestibules(s). Args: - space (): - An OpenStudio space. + spaces (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: @@ -1842,74 +2029,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(spaces, cl): + spaces = [spaces] + elif not isinstance(spaces, list): + return oslg.mismatch("spaces", spaces, list, mth, CN.DBG, False) - if space.additionalProperties().hasFeature("vestibule"): - val = space.additionalProperties().getFeatureAsBoolean("vestibule") + for space in spaces: + 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(spaces=None): + """Validates whether one or more spaces can be considered + indirectly-conditioned plenum(s). Args: - space (openstudio.model.Space): - An OpenStudio space. + spaces (list): + 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?": @@ -1918,7 +2106,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 @@ -1948,13 +2136,13 @@ def is_plenum(space=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"). # - # 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 @@ -1963,7 +2151,7 @@ def is_plenum(space=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. @@ -1972,46 +2160,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(spaces, cl): + spaces = [spaces] + elif not isinstance(spaces, list): + return oslg.mismatch("spaces", spaces, list, mth, CN.DBG, False) - if space.partofTotalFloorArea(): return False - if is_vestibule(space): return False + for space in spaces: + 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" is 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): @@ -2053,23 +2252,23 @@ def setpoints(space=None): cnd = None # 2. Check instead OSut's INDIRECTLYCONDITIONED (parent space) link. - if not cnd: - id = space.additionalProperties().getFeatureAsString(tg2) + if cnd is None: + 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: - 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 = has_heatingTemperatureSetpoints(space.model()) - cooled = has_coolingTemperatureSetpoints(space.model()) + heated = hasHeatingTemperatureSetpoints(space.model()) + cooled = hasCoolingTemperatureSetpoints(space.model()) zone = space.thermalZone() if heated or cooled: @@ -2093,14 +2292,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: @@ -2110,7 +2309,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): @@ -2122,7 +2321,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: @@ -2133,14 +2332,14 @@ 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" 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) @@ -2148,7 +2347,7 @@ def is_refrigerated(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)" % (ide, status, mth)) # 2. Else, compare design heating/cooling setpoints. stps = setpoints(space) @@ -2159,7 +2358,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). @@ -2171,12 +2370,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) @@ -2214,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 @@ -2224,11 +2423,11 @@ 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 - if not limits: + if limits is None: limits = openstudio.model.ScheduleTypeLimits(model) limits.setName("HVAC Operation ScheduleTypeLimits") limits.setLowerLimitValue(0) @@ -2373,7 +2572,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() @@ -2395,7 +2594,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" @@ -2418,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: @@ -2439,16 +2638,16 @@ 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 -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. @@ -2459,7 +2658,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() @@ -2469,7 +2668,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) @@ -2486,7 +2685,7 @@ def to_p3Dv(pts=None) -> openstudio.Point3dVector: return v -def is_same_vtx(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: @@ -2502,262 +2701,6057 @@ def is_same_vtx(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 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) + s2.rotate(-indx) s2 = list(s2) # 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 -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 - that the 'sides' list relies on space coordinates (not building or site - coordinates). Also, the 'sides' list is exclusive (not inclusive), e.g. - walls strictly facing north or east would not be returned if 'sides' holds - ["north", "east"]. No outside boundary condition filters if 'boundary' - argument == "all". No surface type filters if 'type' argument == "all". +def holds(pts=None, p1=None) -> bool: + """Returns True if an OpenStudio 3D point is part of a set of 3D points. Args: - spaces (list of openstudio.model.Space): - Target spaces. - boundary (str): - OpenStudio outside boundary condition. - type (str): - OpenStudio surface (or subsurface) type. - sides (list): - Direction keys, e.g. "north" (see osut.sidz()) + pts (openstudio.Point3dVector): + An OpenStudio vector of 3D points. + p1 (openstudio.Point3d): + An OpenStudio 3D point. Returns: - list of openstudio.model.Surface: Surfaces (may be empty, no logs). - list of openstudio.model.SubSurface: SubSurfaces (may be empty, no logs). - """ - mth = "osut.facets" - - spaces = [spaces] if isinstance(spaces, openstudio.model.Space) else spaces - - try: - spaces = list(spaces) - except: - return [] + bool: Whether part of a set of 3D points. + False: If invalid inputs (see logs). - sides = [sides] if isinstance(sides, str) else sides + """ + mth = "osut.holds" + pts = p3Dv(pts) + cl = openstudio.Point3d - try: - sides = list(sides) - except: - return [] + if not isinstance(p1, cl): + return oslg.mismatch("point", p1, cl, mth, CN.DBG, False) - faces = [] - boundary = oslg.trim(boundary).lower() - type = oslg.trim(type).lower() - if not boundary: return [] - if not type: return [] + for pt in pts: + if areSame(p1, pt): return True - # Filter sides. If 'sides' is initially empty, return all surfaces of - # matching type and outside boundary condition. - if sides: - sides = [side for side in sides if side in sidz()] + return False - if not sides: return [] - for space in spaces: - if not isinstance(space, openstudio.model.Space): return [] +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. - for s in space.surfaces(): - if boundary != "all": - if s.outsideBoundaryCondition().lower() != boundary: continue + Args: + pts (openstudio.Point3dVector): + An OpenStudio vector of 3D points. + p1 (openstudio.Point3d): + An OpenStudio 3D point of reference. - if type != "all": - if s.surfaceType().lower() != type: continue + Returns: + int: Vector index of nearest point to point of reference. + None: If invalid input (see logs). - if sides: - aims = [] + """ + mth = "osut.nearest" + l = 100 + d01 = 10000 + d02 = 0 + d03 = 0 + idx = None + pts = 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 areSame(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. - if s.outwardNormal().z() > CN.TOL: aims.append("top") - if s.outwardNormal().z() < -CN.TOL: aims.append("bottom") - if s.outwardNormal().y() > CN.TOL: aims.append("north") - if s.outwardNormal().x() > CN.TOL: aims.append("east") - if s.outwardNormal().y() < -CN.TOL: aims.append("south") - if s.outwardNormal().x() < -CN.TOL: aims.append("west") + Args: + pts (openstudio.Point3dVector): + An OpenStudio vector of 3D points. + p1 (openstudio.Point3d): + An OpenStudio 3D point of reference. - if all([side in aims for side in sides]): - faces.append(s) - else: - faces.append(s) + Returns: + int: Vector index of farthest point from point of reference. + None: If invalid input (see logs). - for space in spaces: - for s in space.surfaces(): - if boundary != "all": - if s.outsideBoundaryCondition().lower() != boundary: continue + """ + mth = "osut.farthest" + l = 100 + d01 = 0 + d02 = 10000 + d03 = 10000 + idx = None + pts = 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 areSame(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. - for sub in s.subSurfaces(): - if type != "all": - if sub.subSurfaceType().lower() != type: continue + Args: + pts (openstudio.Point3dVector): + An OpenStudio vector of 3D points. + axs (str): + Selected "x", "y" or "z" axis. + val (float): + Axis value. - if sides: - aims = [] + Returns: + openstudio.Point3dVector: flattened points (see logs if empty) + """ + mth = "osut.flatten" + pts = p3Dv(pts) + v = openstudio.Point3dVector() - if sub.outwardNormal().z() > CN.TOL: aims.append("top") - if sub.outwardNormal().z() < -CN.TOL: aims.append("bottom") - if sub.outwardNormal().y() > CN.TOL: aims.append("north") - if sub.outwardNormal().x() > CN.TOL: aims.append("east") - if sub.outwardNormal().y() < -CN.TOL: aims.append("south") - if sub.outwardNormal().x() < -CN.TOL: aims.append("west") + try: + val = float(val) + except: + return oslg.mismatch("val", val, float, mth, CN.DBG, v) - if all([side in aims for side in sides]): - faces.append(sub) - else: - faces.append(sub) + 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 faces + return v -def genSlab(pltz=[], z=0): - """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 - preceding plates in the array. The generated slab may not be convex. +def shareXYZ(pts=None, axs="z", val=0) -> bool: + """Validates whether 3D points share X, Y or Z coordinates. Args: - pltz (list): - Collection of individual floor plates (dicts), each holding: - - "x" (float): Left corner of plate origin (bird's eye view). - - "y" (float): Bottom corner of plate origin (bird's eye view). - - "dx" (float): Plate width (bird's eye view). - - "dy" (float): Plate depth (bird's eye view) - - "z" (float): Z-axis coordinate. + pts (openstudio.Point3dVector): + An OpenStudio vector of 3D points. + axs (str): + Selected "x", "y" or "z" axis. + val (float): + Axis value. Returns: - openstudio.point3dVector: Slab vertices (see logs if empty). + bool: If points share X, Y or Z coordinates. + False: If invalid inputs (see logs). + """ - mth = "osut.genSlab" - slb = openstudio.Point3dVector() - bkp = openstudio.Point3dVector() + mth = "osut.shareXYZ" + pts = p3Dv(pts) + if not pts: return False - # Input validation. - if not isinstance(pltz, list): - return oslg.mismatch("plates", pltz, list, mth, CN.DBG, slb) + try: + val = float(val) + except: + return oslg.mismatch("val", val, float, mth, CN.DBG, False) try: - z = float(z) + axs = str(axs) except: - return oslg.mismatch("Z", z, float, mth, CN.DBG, slb) + 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.z() - val) > CN.TOL: return False + else: + return invalid("axis", mth, 2, CN.DBG, False) - for i, plt in enumerate(pltz): - id = "plate # %d (index %d)" % (i+1, i) + return True - if not isinstance(plt, dict): - return oslg.mismatch(id, 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) +def nextUp(pts=None, pt=None): + """Returns next sequential point in an OpenStudio 3D point vector. - x = plt["x" ] - y = plt["y" ] - dx = plt["dx"] - dy = plt["dy"] + Args: + pts (openstudio.Point3dVector): + An OpenStudio vector of 3D points. + p1 (openstudio.Point3d): + An OpenStudio 3D point of reference. - try: - x = float(x) - except: - oslg.mismatch("%s X" % id, x, float, mth, CN.DBG, slb) + Returns: + openstudio.Point3d: The next sequential 3D point. + None: If invalid inputs (see logs). - try: - y = float(y) - except: - oslg.mismatch("%s Y" % id, y, float, mth, CN.DBG, slb) + """ + mth = "osut.nextUP" + pts = p3Dv(pts) + cl = openstudio.Point3d - try: - dx = float(dx) - except: - oslg.mismatch("%s dX" % id, dx, float, mth, CN.DBG, slb) + if not isinstance(pt, cl): + return oslg.mismatch("point", pt, cl, mth) - try: - dy = float(dy) - except: - oslg.mismatch("%s dY" % id, dy, float, mth, CN.DBG, slb) + if len(pts) < 2: + return oslg.invalid("points (2+)", mth, 1, CN.WRN) - 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) + for pair in each_cons(pts, 2): + if areSame(pair[0], pt): return pair[-1] - # Join plates. - for i, plt in enumerate(pltz): - id = "plate # %d (index %d)" % (i+1, i) + return pts[0] - x = plt["x" ] - y = plt["y" ] - dx = plt["dx"] - dy = plt["dy"] - # Adjust X if dX < 0. - if dx < 0: x -= -dx - if dx < 0: dx = -dx +def width(pts=None) -> float: + """Returns 'width' of a set of OpenStudio 3D points. - # Adjust Y if dY < 0. - if dy < 0: y -= -dy - if dy < 0: dy = -dy + Args: + pts (openstudio.Point3dVector): + An OpenStudio vector of 3D points. - vtx = [] - vtx.append(openstudio.Point3d(x + dx, y + dy, 0)) - vtx.append(openstudio.Point3d(x + dx, y, 0)) - vtx.append(openstudio.Point3d(x, y, 0)) - vtx.append(openstudio.Point3d(x, y + dy, 0)) + Returns: + float: 'Width' along X-axis. + 0.0: If invalid input (see logs). + """ + pts = p3Dv(pts) + if len(pts) < 2: return 0 - if slb: - slab = openstudio.join(slb, vtx, CN.TOL2) + xs = [pt.x() for pt in pts] - if slab: - slb = slab.get() - else: - return oslg.invalid(id, mth, 0, CN.ERR, bkp) - else: - slb = vtx + return max(xs) - min(xs) + + +def height(pts=None) -> float: + """Returns 'height' 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 = 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 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 areSame(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 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: + 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 uniques(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.uniques" + pts = 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 = uniques(pts) + if len(pts) < 2: return vv + + for i1, p1 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 isSegment(pts=None) -> bool: + """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 = p3Dv(pts) + if len(pts) != 2: return False + if areSame(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 = uniques(pts) + if len(pts) < 2: return vv + + 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] + + tri = openstudio.Point3dVector() + tri.append(p1) + tri.append(p2) + tri.append(p3) + vv.append(tri) + + return vv + + +def isTriad(pts=None) -> bool: + """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 = p3Dv(pts) + if len(pts) != 3: 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=[]) -> bool: + """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 = segments(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 + + +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 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 collinear or parallel. + 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) + + 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 + + # 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() + if abs(b.dot(n)) < CN.TOL: return None + 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 isPointAlongSegment(p0, s2): return None + if not isPointAlongSegment(p0, s1): return None + + return p0 + + +def doesLineIntersect(l=[], s=[]) -> bool: + """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) -> 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 (openstudio.Point3dVector): + An OpenStudio vector of pre-aligned 3D points. + + Returns: + bool: Whether sequence is clockwise. + False: If invalid input (see logs). + + """ + mth = "osut.isClockwise" + pts = 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) -> 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 (openstudio.Point3dVector): + An OpenStudio vector of pre-aligned 3D points. + + Returns: + openstudio.Point3dVector: ULC points (see logs if empty). + """ + mth = "osut.ulc" + v = openstudio.Point3dVector() + pts = list(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 p3Dv(list(pts)) + + +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 (openstudio.Point3dVector): + An OpenStudio vector of pre-aligned 3D points. + + Returns: + openstudio.Point3dVector: BLC points (see logs if empty). + """ + mth = "osut.blc" + v = openstudio.Point3dVector() + pts = list(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 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 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 = collections.deque(a) + 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 n > 0: a = a[0:n] + if n < 0: a = a[n:] + + return 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 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 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 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 = 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 = list(t.inverse() * pts) + at.reverse() + + if isinstance(tt, cl): + att = list(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 = list(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 = p3Dv(t * ulc(a)) if t else p3Dv(ulc(a)) + elif sq == "blc": + a = p3Dv(t * blc(a)) if t else p3Dv(blc(a)) + elif sq == "cw": + a = p3Dv(t * a) if t else p3Dv(a) + else: + a = p3Dv(t * a) if t else p3Dv(a) + else: + if sq == "ulc": + a = p3Dv(ulc(a)) if tt else p3Dv(t * ulc(a)) + elif sq == "blc": + a = p3Dv(blc(a)) if tt else p3Dv(t * blc(a)) + elif sq == "cw": + a = p3Dv(a) if tt else p3Dv(t * a) + else: + a = p3Dv(a) if tt else 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(entirely, 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 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, True): 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 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 + 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() + a1 = list(a1) + else: + 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) + + 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) + 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(area1, 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 = list(res.polygon1()) + res1.reverse() + if not res1: return face + if t: res1 = list(t * res1) + + return p3Dv(res1) + + +def overlapping(p1=None, p2=None, flat=False) -> bool: + """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 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 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) + offst = openstudio.buffer(pts, w, CN.TOL) + if not offst: return p1 + + offst = offst.get() + offst.reverse() + return p3Dv(list(t * offst)) + 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) + if bfr < 0.0254: bfr = 0 + 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 + + 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) < CN.TOL: + return oslg.zero("outline width", mth, CN.DBG, out) + if abs(yMIN - yMAX) < CN.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 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 = list(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 len(box) != 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 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 '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 + 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 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 = sorted(sgs, key=lambda x: x["mo"])[:2] + else: + sgs = sorted(sgs, key=lambda x: x["l" ])[:2] + sgs = sorted(sgs, key=lambda x: x["mo"])[:2] + + sg0 = sgs[0] + sg1 = sgs[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) + st = p3Dv(t.inverse() * pts) + box = p3Dv(t.inverse() * box) + bbox = outline([st]) + + out["set" ] = blc(st) + out["box" ] = blc(box) + out["bbox"] = blc(bbox) + out["t" ] = t + out["r" ] = r + out["o" ] = origin + + 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 = 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 = 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 = 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 = realignedFace(pts, force)["set"] + if len(pts) < 2: return 0 + + ys = [pt.y() for pt in pts] + + return max(ys) - min(ys) + + +def spaceHeight(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(): + 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 + + 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, 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 + 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. + sset (list): + Subsets of (smaller) sequenced points, to 'anchor'. + tag (str): + Selected subset vertices to target. + + Returns: + int: Number of successfully anchored subsets (see logs if missing). + + """ + mth = "osut.genAnchors" + n = 0 + ide = "%s " % s.nameString() if hasattr(s, "nameString") else "" + ids = id(s) + pts = poly(s) + + if not pts: + return oslg.invalid("%s polygon" % ide, mth, 1, CN.DBG, n) + + try: + sset = list(sset) + except: + 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(sset): + 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) + + 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 "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: + if not isinstance(st["ld"], dict): + return oslg.invalid("%s leaders" % str1, mth, 0, CN.DBG, n) + + if ids in st["ld"]: st["ld"].pop(ids) + else: + st["ld"] = {} + + 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"). + # The latter is typically generated from an outdoor-facing roof. + # Subsequent calls to 'genAnchors' may send (as first argument) a + # 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), + # 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(sset): + 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 sset: + 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 sset: + if nb != 0: break + if st == other: continue + if "ld" not in other: continue + if ids not in other["ld"]: continue + + ost = other[tag] + pld = other["ld"][ids] + 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[-1]).cross(ld[0]-ld[-1])).length() < CN.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 + + st["ld"][ids] = p0 + n += 1 + else: + 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 + + +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 + 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. + sset (list): + Subsets of (smaller) sequenced points. + tag (str): + Selected subset vertices to target. + + Returns: + openstudio.Point3dVector: Extended vertices (see logs if empty). + + """ + mth = "osut.genExtendedVertices" + ide = "%s " % s.nameString() if hasattr(s, "nameString") else "" + f = False + ids = id(s) + pts = poly(s) + cl = openstudio.Point3d + a = openstudio.Point3dVector() + v = [] + + if not pts: return oslg.invalid("%s polygon" % ide, mth, 1, CN.DBG, a) + + try: + sset = list(sset) + except: + return oslg.mismatch("subset", sset, list, mth, CN.DBG, a) + + # Validate individual subsets. + for i, st in enumerate(sset): + 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, 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(st["ld"], dict): + return oslg.invalid("%s leaders" % str2, mth, 0, 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[ids], cl): + return oslg.mismatch("%s point" % str2, st["ld"][ids], cl, mth, CN.DBG, a) + + # Re-sequence polygon vertices. + for pt in pts: + v.append(pt) + + # 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 + + v += list(st[tag]) + v.append(pt) + + return p3Dv(v) + + +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 + 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. + 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 + - "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 "" + ids = id(s) + pts = poly(s) + cl = openstudio.Point3d + a = openstudio.Point3dVector() + if not pts: return a + + try: + sset = list(sset) + except: + return oslg.mismatch("subset", sset, list, mth, CN.DBG, a) + + gap = 0.1 + gap4 = 0.4 # minimum insert width/depth + + # 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 + + 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 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. + # @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 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(sset): + if i == j: continue + bx2 = other["box"] + + if overlapping(bx, bx2): + str4 = ide + "subset 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(sset): + 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. + return genExtendedVertices(s, sset) + + +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 + that the 'sides' list relies on space coordinates (not building or site + coordinates). Also, the 'sides' list is exclusive (not inclusive), e.g. + walls strictly facing north or east would not be returned if 'sides' holds + ["north", "east"]. No outside boundary condition filters if 'boundary' + argument == "all". No surface type filters if 'type' argument == "all". + + Args: + spaces (list of openstudio.model.Space): + Target spaces. + boundary (str): + OpenStudio outside boundary condition. + type (str): + OpenStudio surface (or subsurface) type. + sides (list): + Direction keys, e.g. "north" (see osut.sidz()) + + Returns: + list of openstudio.model.Surface: Surfaces (may be empty, no logs). + list of openstudio.model.SubSurface: SubSurfaces (may be empty, no logs). + """ + mth = "osut.facets" + + spaces = [spaces] if isinstance(spaces, openstudio.model.Space) else spaces + + try: + spaces = list(spaces) + except: + return [] + + sides = [sides] if isinstance(sides, str) else sides + + try: + sides = list(sides) + except: + return [] + + faces = [] + boundary = oslg.trim(boundary).lower() + type = oslg.trim(type).lower() + if not boundary: return [] + if not type: return [] + + # Filter sides. If 'sides' is initially empty, return all surfaces of + # matching type and outside boundary condition. + if sides: + sides = [side for side in sides if side in sidz()] + + if not sides: return [] + + for space in spaces: + if not isinstance(space, openstudio.model.Space): return [] + + for s in space.surfaces(): + if boundary != "all": + if s.outsideBoundaryCondition().lower() != boundary: continue + + if type != "all": + if s.surfaceType().lower() != type: continue + + if sides: + aims = [] + + if s.outwardNormal().z() > CN.TOL: aims.append("top") + if s.outwardNormal().z() < -CN.TOL: aims.append("bottom") + if s.outwardNormal().y() > CN.TOL: aims.append("north") + if s.outwardNormal().x() > CN.TOL: aims.append("east") + if s.outwardNormal().y() < -CN.TOL: aims.append("south") + if s.outwardNormal().x() < -CN.TOL: aims.append("west") + + if all([side in aims for side in sides]): + faces.append(s) + else: + faces.append(s) + + for space in spaces: + for s in space.surfaces(): + if boundary != "all": + if s.outsideBoundaryCondition().lower() != boundary: continue + + for sub in s.subSurfaces(): + if type != "all": + if sub.subSurfaceType().lower() != type: continue + + if sides: + aims = [] + + if sub.outwardNormal().z() > CN.TOL: aims.append("top") + if sub.outwardNormal().z() < -CN.TOL: aims.append("bottom") + if sub.outwardNormal().y() > CN.TOL: aims.append("north") + if sub.outwardNormal().x() > CN.TOL: aims.append("east") + if sub.outwardNormal().y() < -CN.TOL: aims.append("south") + if sub.outwardNormal().x() < -CN.TOL: aims.append("west") + + if all([side in aims for side in sides]): + faces.append(sub) + else: + faces.append(sub) + + return faces + + +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 + preceding plates in the array. The generated slab may not be convex. + + Args: + pltz (list): + Collection of individual floor plates (dicts), each holding: + - "x" (float): Left corner of plate origin (bird's eye view). + - "y" (float): Bottom corner of plate origin (bird's eye view). + - "dx" (float): Plate width (bird's eye view). + - "dy" (float): Plate depth (bird's eye view) + - "z" (float): Z-axis coordinate. + + Returns: + openstudio.Point3dVector: Slab vertices (see logs if empty). + """ + mth = "osut.genSlab" + slb = openstudio.Point3dVector() + bkp = openstudio.Point3dVector() + + # Input validation. + if not isinstance(pltz, list): + return oslg.mismatch("plates", pltz, list, mth, CN.DBG, slb) + + try: + z = float(z) + except: + return oslg.mismatch("Z", z, float, mth, CN.DBG, slb) + + for i, plt in enumerate(pltz): + ide = "plate # %d (index %d)" % (i+1, i) + + if not isinstance(plt, dict): + return oslg.mismatch(ide, plt, dict, 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" ] + dx = plt["dx"] + dy = plt["dy"] + + try: + x = float(x) + except: + oslg.mismatch("%s X" % ide, x, float, mth, CN.DBG, slb) + + try: + y = float(y) + except: + oslg.mismatch("%s Y" % ide, y, float, mth, CN.DBG, slb) + + try: + dx = float(dx) + except: + oslg.mismatch("%s dX" % ide, dx, float, mth, CN.DBG, slb) + + try: + dy = float(dy) + except: + oslg.mismatch("%s dY" % ide, dy, float, mth, CN.DBG, 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): + ide = "plate # %d (index %d)" % (i+1, i) + + x = plt["x" ] + y = plt["y" ] + dx = plt["dx"] + dy = plt["dy"] + + # Adjust X if dX < 0. + if dx < 0: x -= -dx + if dx < 0: dx = -dx + + # Adjust Y if dY < 0. + if dy < 0: y -= -dy + if dy < 0: dy = -dy + + vtx = [] + vtx.append(openstudio.Point3d(x + dx, y + dy, 0)) + vtx.append(openstudio.Point3d(x + dx, y, 0)) + vtx.append(openstudio.Point3d(x, y, 0)) + vtx.append(openstudio.Point3d(x, y + dy, 0)) + + if slb: + slab = openstudio.join(slb, vtx, CN.TOL2) + + if slab: + slb = slab.get() + else: + return oslg.invalid(ide, mth, 0, CN.ERR, bkp) + else: + slb = vtx # Once joined, re-adjust Z-axis coordinates. if abs(z) > CN.TOL: vtx = openstudio.Point3dVector() - for pt in slb: vtx.append(openstudio.Point3d(pt.x(), pt.y(), z)) + for pt in slb: vtx.append(openstudio.Point3d(pt.x(), pt.y(), z)) + + 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): + A collection 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) + rufs = [] + + 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. + 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 + # 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 rufs: rufs.append(ruf) + + 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. + + 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): subs = [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 enumerate(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" ] = type + + 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) + + 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 + + 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)" % (ide, 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)" % (ide, mth) + oslg.log(CN.WRN, m) + else: + m = "Skip '%s' invalid FrameDivider object (%s)" % (ide, 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)" % (ide, mth) + oslg.log(WRN, m) + sub["assembly"] = None + + # Log/reset negative float values. Set ~0.0 values to 0.0. + for key, value in sub.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. + ide = 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 = "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)" % (ide, 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 = "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)" % (ide, sub["head"], mth) + oslg.log(CN.WRN, m) + + # Log/reset "sill" height if beyond min/max. + if "sill" in sub: + if (sub["sill"] < min_sill - CN.TOL2 or + sub["sill"] > max_sill + CN.TOL2): + + 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)" % (ide, sub["sill"], mth) + 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. + # 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)" % (ide, mth) + oslg.log(CN.ERR, m) + continue + else: + 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)" % (ide, 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: + hght = sub["head"] - sub["sill"] + + if "height" in sub and abs(sub["height"] - hght) > CN.TOL2: + 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) + + 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 + hght = sub["head"] - sill + + if hght < 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)" % (ide, mth) + oslg.log(CN.ERR, m) + continue + else: + 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)" % (ide, sub["height"], mth) + oslg.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 + hght = head - sub["sill"] + + if hght < 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)" % (ide, mth) + oslg.log(CN.ERR, m) + continue + else: + 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)" % (ide, 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 = "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)" % (ide, 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)" % (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)" % (ide, sub["l_buffer"], mth) + oslg.log(WRN, m) + sub["l_buffer"] = min_ljamb + 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)" % (ide, sub["r_buffer"], mth) + oslg.log(CN.WRN, m) + sub["r_buffer"] = min_rjamb + m = "Right buffer '%s' reset to %.3fm (%s)" % (ide, sub["r_buffer"], mth) + oslg.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 = "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) + 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 + wdth = 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)" % (ide, 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)" % (ide, 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)" % (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)" % (ide, sub["width"], mth) + oslg.log(CN.WRN, m) + sub["width"] = wdth + 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 + + 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})" % (ide, mth)) + continue + + wdth = sub["width"] + frames + gap = (max_x - n * wdth) / (n + 1) + + if "offset" in sub: gap = sub["offset"] - wdth + if gap < buffer: gap = 0 + + offst = gap + wdth + + if "offset" in sub and abs(offst - sub["offset"]) > CN.TOL: + 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)" % (ide, sub["offset"], mth) + oslg.log(CN.WRN, m) + + if "offset" not in sub: sub["offset"] = offst + + # Overall width (including frames) of bounding box around array. + w = n * wdth + (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)" % (ide, 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)" % (ide, mth) + oslg.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" % (ide, 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(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) + 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 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): + A collection 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 = {} - slb = vtx + if isinstance(spaces, openstudio.model.Space): spaces = [spaces] - return slb + 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. + + # Start with roof surfaces of occupied, conditioned spaces. + for space in spaces: + for roof in facets(space, "Outdoors", "RoofCeiling"): + ide = roof.nameString() + if ide in rfs: continue + if not isRoof(roof): continue + + rfs[ide] = 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"): + ide = roof.nameString() + if ide in rfs: continue + if not isRoof(roof): continue + + 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). + 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"): + ide = roof.nameString() + if not isRoof(roof): continue + + rvi = ti * roof.vertices() + cst = cast(cv0, rvi, up) + if not cst: continue + + # 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 + + m2 = openstudio.getArea(olap) + if not m2: continue + + m2 = m2.get() + if m2 < CN.TOL2: continue + if ide not in rfs: rfs[ide] = dict(m2=0, m=other.multiplier()) + + rfs[ide]["m2"] += m2 + + for rf in rfs.values(): + rm2 += rf["m2"] * rf["m"] + + return rm2 + + +def horizontalRidges(rufs=[]) -> list: + """Identifies horizontal ridges along 2x sloped 'roof' surfaces (same + space) - see 'isRoof'. Harmonized with OpenStudio's "alignZPrime" - see + 'isSloped'. + + Args: + rufs (list): + A Collection of 'roof' openstudio.model.Surface instances. + + Returns: + 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 + + """ + mth = "osut.horizontalRidges" + ridges = [] + + try: + rufs = list(rufs) + except: + return ridges + + 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 rufs: + 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 rufs: + 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 = list(edg) + edg2.reverse() + + if areSame(edge, edg1) or areSame(edge, edg2): + ridge["roofs"].append(ruf) + ridges.append(ridge) + match = True + + 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): + 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) + + 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 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=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 + # 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=lambda s: s.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: + ide = s.nameString() + m2 = s.floorArea() + + for rf in roofs(s): + 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. + 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=lambda s: s.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=lambda ruf: ruf.grossArea(), reverse=True) + + toits.append(rfs[0]) + rooms.append(s["space"]) + + if not rooms: oslg.log(CN.INF, "No ideal toplit candidates (%s)" % mth) + + 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. + 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 + 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 'subset' 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[ide]["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 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 + + 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[ide]["sidelit"] + sset["sloped" ] = isSloped(roof) + sset["t0" ] = rooms[ide]["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(): + t0 = room["t0"] + space = room["space"] + rufs = [ruf for ruf in roofs(space) if ruf not in 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 clng in facets(space, "Surface", "RoofCeiling"): + idee = clng.nameString() + tpts = t0 * clng.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 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 ceiling 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 = clng.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" ] = clng # 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 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. + 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" ] = clng + sset["t0" ] = t0 + 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() + 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) + + sset["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) + + sset["plenum"] = espace + + ceilings[idee]["plenum"] = espace # adjacent plenum (floor) + + ssets.append(sset) + break # only 1x unique ruf/ceiling pair. + + # Ensure uniqueness of plenum roofs. + for attic in attics.values(): + 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(): + 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 + # 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 = 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] + 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. + ssets = [sset for sset in ssets if "void" not in sset] + + # 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["clng" ] # ceiling surface + space = ceiling["space"] # its space + espace = ceiling[k] # adjacent (unoccupied) space above + if "roofs" not in ceiling: continue + + stz = [] + + for roof in ceiling["roofs"]: + sts = ssets + + 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(clng, stz, "cbox") + + # Delete voided sets. + ssets = [sset for sset in ssets if "void" not in sset] + if not ssets: return oslg.empty("subsets", mth, CN.WRN, rm2) + + # 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(sset["sidelit"] for sset in ssets) + sloped = any(sset["sloped" ] for sset in ssets) + + # Average sandbox area + revised 'working' SRR%. + 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 + # 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, sset in enumerate(ssets): + thin = sset["thin"] + tight = sset["tight"] + factor = 1.75 if tight else 1.25 + well = "clng" in sset + space = sset["space"] + room = rooms[space.nameString()] + h = room["h"] + 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 subset if too narrow/shallow to hold a single skylight. + if well: + if round(width, 2) < round(wl, 2): + 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, "subset #{i+1} well: Too shallow (%s)" % mth) + sset["void"] = True + continue + else: + if round(width, 2) < round(w0, 2): + 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, "subset #{i+1}: Too shallow (%s)" % mth) + sset["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 = 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)) + + 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 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 + + 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 + + sset[pattern] = st + + if not any(pattern in sset for pattern in patterns): sset["void"] = True + + # 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] + 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 + flters = [] + flters = [fil for fil in filters if fil not in flters] # ensure uniqueness + filters = flters + + # 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 = [sset for sset in ssets if "pattern" not in sset] + + 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 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"])) + mnM2 = list(fpm2.values())[ 0]["m2"] + mxM2 = list(fpm2.values())[-1]["m2"] + + 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). + 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 subsets. + for st in sts: + 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 sset: continue + if st["clng"] != sset["clng"]: continue + + 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 "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] + 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 subsets altogether if their total m2 < (skm2 - sm2). Only + # considered if significant discrepancies vs average subset skylight m2. + sbm2 = 0 + + for sset in ssets: + sbm2 += sset["cols"] * sset["w"] * sset["rows"] * sset["d"] * sset["m"] + + avm2 = sbm2 / len(ssets) + + if round(skm2, 2) > round(sm2, 2): + ssets.reverse() + + for sset in ssets: + if round(skm2, 2) <= round(sm2, 2): break + + 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 + sset["void"] = True + + ssets.reverse() + + 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 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 + + if yr > w0: + yr = w0 if yr * ratio < w0 else yr * ratio + + xm2 = sset["cols"] * xr * sset["rows"] * yr * sset["m"] + if round(xm2, 2) == round(am2, 2): continue + + sset["dY"] += (sset["d"] - yr) / 2 + if "dX" in sset: sset["dX"] += (sset["w"] - xr) / 2 + + 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 subsets. + adm2 = 0 + + for sset in ssets: + if round(sset["w"], 2) <= w0: continue + if round(sset["d"], 2) <= w0: continue + + 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 sset in ssets: + if round(sset["w"], 2) <= w0: continue + if round(sset["d"], 2) <= w0: continue + + 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 + + if yr > w0: + yr = yw0 if r * ratio < w0 else yr * ratio + + xm2 = sset["cols"] * xr * sset["rows"] * yr * sset["m"] + if round(xm2, 2) == round(am2, 2): continue + + sset["dY"] += (sset["d"] - yr) / 2 + if "dX" in sset: sset["dX"] += (sset["w"] - xr) / 2 + + sset["w" ] = xr + sset["d" ] = yr + sset["w0"] = sset["w"] + gap + sset["d0"] = sset["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 sset in ssets: + if round(skm2, 2) <= round(sm2, 2): break + + 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 + + if yr > gap4: + yr = gap4 if yr * ratio < gap4 else yr * ratio + + xm2 = sset["cols"] * xr * sset["rows"] * yr * sset["m"] + if round(xm2, 2) == round(am2, 2): continue + + sset["dY"] += (sset["d"] - yr) / 2 + if "dX" in sset: sset["dX"] += (sset["w"] - xr) / 2 + + sset["w" ] = xr + sset["d" ] = yr + sset["w0"] = sset["w"] + gap + sset["d0"] = sset["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 = 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] + 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] + + 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] + + 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["clng" ] # 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 = 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"]] + + 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]) + + 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(): + vec = p3Dv(t0.inverse() * list(ti * vt)) + roof = openstudio.model.Surface(vec, 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 subset 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 subset roof "vtx" onto ceiling. + for st in stz: + cst = cast(ti * st["vtx"], t0 * clng.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(ssets): + 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 "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) + + return rm2 diff --git a/tests/test_osut.py b/tests/test_osut.py index 1f24dad..76d1378 100644 --- a/tests/test_osut.py +++ b/tests/test_osut.py @@ -27,14 +27,11 @@ # 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 collections import unittest import openstudio -import osut +from src.osut import osut DBG = osut.CN.DBG INF = osut.CN.INF @@ -43,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): @@ -51,7 +50,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) @@ -93,7 +92,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,10 +100,10 @@ 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) + del model # Unsuccessful try: 1st argument not a model (see 'm2'). model = openstudio.model.Model() @@ -114,10 +112,10 @@ 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) + del model # Defaulted specs (2nd argument). specs = dict() @@ -139,7 +137,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). @@ -160,7 +158,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) @@ -180,7 +178,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) @@ -202,7 +200,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") @@ -224,7 +222,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) @@ -247,7 +245,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") @@ -268,7 +266,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") @@ -284,7 +282,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") @@ -300,7 +298,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") @@ -316,7 +314,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") @@ -332,7 +330,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") @@ -348,7 +346,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) @@ -370,7 +368,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") @@ -392,7 +390,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") @@ -413,7 +411,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") @@ -429,7 +427,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") @@ -450,7 +448,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) @@ -472,7 +470,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") @@ -494,7 +492,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") @@ -511,7 +509,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") @@ -533,7 +531,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") @@ -549,7 +547,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") @@ -571,7 +569,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") @@ -591,7 +589,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) @@ -611,7 +609,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() @@ -630,7 +628,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() @@ -649,14 +647,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() @@ -727,13 +724,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() @@ -788,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.areSame(vtx, s.vertices()[i])) self.assertEqual(adj.surfaceType(), "RoofCeiling") self.assertEqual(s.surfaceType(), "RoofCeiling") @@ -802,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.areSame(vtx, s.vertices()[i])) # After the fix. if version >= 350: @@ -821,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: @@ -833,23 +829,23 @@ 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 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()) 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) @@ -857,14 +853,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() @@ -959,15 +954,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() @@ -981,11 +975,11 @@ 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) + del model # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # path = openstudio.path("./tests/files/osms/out/seb2.osm") @@ -994,22 +988,21 @@ 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) 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() @@ -1096,14 +1089,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() @@ -1185,14 +1177,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() @@ -1205,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)) @@ -1229,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: @@ -1273,16 +1264,15 @@ 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("."))) + # version = int("".join(openstudio.openStudioVersion().split("."))) translator = openstudio.osversion.VersionTranslator() path = openstudio.path("./tests/files/osms/out/seb2.osm") @@ -1303,7 +1293,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) @@ -1318,23 +1308,29 @@ 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) - 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() @@ -1409,14 +1405,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() @@ -1491,14 +1486,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() @@ -1571,14 +1565,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() @@ -1595,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() @@ -1631,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 @@ -1642,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 @@ -1653,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 @@ -1664,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,18 +1735,18 @@ 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) + translator = openstudio.osversion.VersionTranslator() + + m = "'model' str? expecting Model (osut.hasAirLoopsHVAC)" - msg = "'model' str? expecting Model (osut.has_airLoopsHVAC)" version = int("".join(openstudio.openStudioVersion().split("."))) - translator = openstudio.osversion.VersionTranslator() # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # path = openstudio.path("./tests/files/osms/out/seb2.osm") @@ -1762,15 +1755,15 @@ 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) + self.assertEqual(o.logs()[0]["message"], m) self.assertEqual(o.clean(), DBG) - del(model) + del model # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # path = openstudio.path("./tests/files/osms/in/5ZoneNoHVAC.osm") @@ -1779,24 +1772,22 @@ 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.logs()[0]["message"], m) 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) - version = int("".join(openstudio.openStudioVersion().split("."))) translator = openstudio.osversion.VersionTranslator() path = openstudio.path("./tests/files/osms/out/seb2.osm") @@ -1806,49 +1797,86 @@ def test19_vestibules(self): # Tag "Entry way 1" in SEB as a vestibule. tag = "vestibule" + m = "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"], m) + self.assertEqual(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) + 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 - 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 = "'spaces' 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) @@ -1888,8 +1916,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) @@ -1909,8 +1937,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) @@ -1921,8 +1949,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) @@ -1932,13 +1960,13 @@ 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) - del(model) + del model # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # path = openstudio.path("./tests/files/osms/in/warehouse.osm") @@ -1950,10 +1978,10 @@ 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) + del model # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # path = openstudio.path("./tests/files/osms/in/smalloffice.osm") @@ -1981,8 +2009,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) @@ -1997,8 +2025,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) @@ -2007,8 +2035,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) @@ -2017,11 +2045,11 @@ 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.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)) @@ -2030,9 +2058,9 @@ 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) + for l in o.logs(): self.assertEqual(l["message"], m) # Now test a valid entry. self.assertTrue(attic.additionalProperties().resetFeature(key)) @@ -2040,10 +2068,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,7 +2081,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 @@ -2062,7 +2090,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() @@ -2270,42 +2297,3056 @@ def test21_availability_schedules(self): self.assertEqual(int(day_schedule.getValue(am01)), 0) self.assertEqual(int(day_schedule.getValue(pm11)), 0) - del(model) - - # def test22_model_transformation(self): - # - # def test23_fits_overlaps(self): - # - # def test24_triangulation(self): - # - # 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): + del model - def test35_facet_retrieval(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) + 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)) + + 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 + self.assertEqual(o.status(), 0) + self.assertEqual(o.reset(DBG), DBG) + self.assertEqual(o.level(), DBG) + version = 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 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)) + 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(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 = osut.nonCollinears(ceiling.vertices(), 3) + box01 = osut.triadBox(pts) + box11 = osut.boundedBox(ceiling) + 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) + + # 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) + + def test24_triangulation(self): + o = osut.oslg + self.assertEqual(o.status(), 0) + self.assertEqual(o.reset(DBG), DBG) + self.assertEqual(o.level(), DBG) + + 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)) + 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) + # for vt0 in list(results[0]): print(vt0) + # [ 0, 10, 0] + # [20, 10, 0] + # [20, 0, 0] + # for vt1 in list(results[1]): print(vt1) + # [ 0, 0, 0] + # [ 0, 10, 0] + # [20, 0, 0] + + 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) + + # 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.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.assertEqual(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)) + + # 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) + self.assertEqual(o.reset(DBG), DBG) + self.assertEqual(o.level(), 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)) + 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): + o = osut.oslg + self.assertEqual(o.status(), 0) + self.assertEqual(o.reset(INF), INF) + self.assertEqual(o.level(), INF) + + # 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): + # 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(DBG), DBG) + self.assertEqual(o.level(), DBG) + 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) + + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + # 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: + 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() + + 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): + 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): + 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. + ide = "Level 0 Open area 1 ceiling Plenum RoofCeiling" + ruf1 = model.getSurfaceByName(ide) + 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 + self.assertEqual(o.status(), 0) + self.assertEqual(o.reset(INF), INF) + self.assertEqual(o.level(), INF) + + 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(): + ide = space.nameString() + + if version >= 350: + self.assertTrue(space.isVolumeAutocalculated) + self.assertTrue(space.isCeilingHeightAutocalculated) + self.assertTrue(space.isFloorAreaDefaulted) + self.assertTrue(space.isFloorAreaAutocalculated) + + if ide == "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 + self.assertEqual(o.status(), 0) + self.assertEqual(o.reset(INF), INF) + self.assertEqual(o.level(), INF) + 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 ide, surface in spaces.items(): + self.assertTrue(ide in roofs) + self.assertEqual(roofs[ide], 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) + self.assertTrue("plenum" in roofs[occ].lower()) + + del model + self.assertEqual(o.status(), 0) + + 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): + 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(): + ide = 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 ide: + self.assertTrue(sidelit) + elif "Core" in ide: + 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) + + # "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)) + 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) + + # 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) + + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + # 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 + + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + # 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 + + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + 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) + self.assertEqual(o.reset(DBG), DBG) + self.assertEqual(o.level(), DBG) + translator = openstudio.osversion.VersionTranslator() path = openstudio.path("./tests/files/osms/out/seb2.osm") @@ -2371,14 +5412,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 @@ -2479,8 +5519,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) @@ -2560,9 +5600,78 @@ 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): + 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()