lib/osut/utils.rb in osut-0.5.0 vs lib/osut/utils.rb in osut-0.6.0
- old
+ new
@@ -32,24 +32,24 @@
module OSut
# DEBUG for devs; WARN/ERROR for users (bad OS input), see OSlg
extend OSlg
- TOL = 0.01 # default distance tolerance (m)
- TOL2 = TOL * TOL # default area tolerance (m2)
- DBG = OSlg::DEBUG # see github.com/rd2/oslg
- INF = OSlg::INFO # see github.com/rd2/oslg
- WRN = OSlg::WARN # see github.com/rd2/oslg
- ERR = OSlg::ERROR # see github.com/rd2/oslg
- FTL = OSlg::FATAL # see github.com/rd2/oslg
- NS = "nameString" # OpenStudio object identifier method
+ TOL = 0.01 # default distance tolerance (m)
+ TOL2 = TOL * TOL # default area tolerance (m2)
+ DBG = OSlg::DEBUG.dup # see github.com/rd2/oslg
+ INF = OSlg::INFO.dup # see github.com/rd2/oslg
+ WRN = OSlg::WARN.dup # see github.com/rd2/oslg
+ ERR = OSlg::ERROR.dup # see github.com/rd2/oslg
+ FTL = OSlg::FATAL.dup # see github.com/rd2/oslg
+ NS = "nameString" # OpenStudio object identifier method
HEAD = 2.032 # standard 80" door
SILL = 0.762 # standard 30" window sill
# General surface orientations (see facets method)
- SIDZ = [:bottom, # e.g. ground-facing, exposed floros
+ SIDZ = [:bottom, # e.g. ground-facing, exposed floors
:top, # e.g. roof/ceiling
:north, # NORTH
:east, # EAST
:south, # SOUTH
:west # WEST
@@ -1922,20 +1922,20 @@
# "plenum" in this 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?", this method
+ # In contrast to OpenStudio-Standards' "space_plenum?", the method below
# 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
# plenum. Beyond these 2x criteria, a space is considered a plenum if:
#
# 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" == 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.
#
# If a modeller is instead simply interested in identifying UNOCCUPIED
@@ -2052,11 +2052,11 @@
unless cnd.nil?
if cnd.downcase == "unconditioned"
res[:heating] = nil
res[:cooling] = nil
elsif cnd.downcase == "semiheated"
- res[:heating] = 15.0 if res[:heating].nil?
+ res[:heating] = 14.0 if res[:heating].nil?
res[:cooling] = nil
elsif cnd.downcase.include?("conditioned")
# "nonresconditioned", "resconditioned" or "indirectlyconditioned"
res[:heating] = 21.0 if res[:heating].nil? # default
res[:cooling] = 24.0 if res[:cooling].nil? # default
@@ -2089,10 +2089,64 @@
ok
end
##
+ # Validates whether a space can be considered as REFRIGERATED.
+ #
+ # @param space [OpenStudio::Model::Space] a space
+ #
+ # @return [Bool] whether space is considered REFRIGERATED
+ # @return [false] if invalid input (see logs)
+ def refrigerated?(space = nil)
+ mth = "OSut::#{__callee__}"
+ cl = OpenStudio::Model::Space
+ tg0 = "refrigerated"
+ return mismatch("space", space, cl, mth, DBG, false) unless space.is_a?(cl)
+
+ # 1. First check OSut's REFRIGERATED status.
+ status = space.additionalProperties.getFeatureAsString(tg0)
+
+ unless status.empty?
+ status = status.get
+ return status if [true, false].include?(status)
+
+ log(ERR, "Unknown #{space.nameString} REFRIGERATED #{status} (#{mth})")
+ end
+
+ # 2. Else, compare design heating/cooling setpoints.
+ stps = setpoints(space)
+ return false unless stps[:heating].nil?
+ return false if stps[:cooling].nil?
+ return true if stps[:cooling] < 15
+
+ false
+ end
+
+ ##
+ # Validates whether a space can be considered as SEMIHEATED as per NECB 2020
+ # 1.2.1.2. 2): design heating setpoint < 15°C (and non-REFRIGERATED).
+ #
+ # @param space [OpenStudio::Model::Space] a space
+ #
+ # @return [Bool] whether space is considered SEMIHEATED
+ # @return [false] if invalid input (see logs)
+ def semiheated?(space = nil)
+ mth = "OSut::#{__callee__}"
+ cl = OpenStudio::Model::Space
+ return mismatch("space", space, cl, mth, DBG, false) unless space.is_a?(cl)
+ return false if refrigerated?(space)
+
+ stps = setpoints(space)
+ return false unless stps[:cooling].nil?
+ return false if stps[:heating].nil?
+ return true if stps[:heating] < 15
+
+ false
+ end
+
+ ##
# Generates an HVAC availability schedule.
#
# @param model [OpenStudio::Model::Model] a model
# @param avl [String] seasonal availability choice (optional, default "ON")
#
@@ -2254,13 +2308,13 @@
# ---- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- ---- #
# ---- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- ---- #
# This final set of utilities targets OpenStudio geometry. Many of the
# following geometry methods rely on Boost as an OpenStudio dependency.
- # As per Boost requirements, points (e.g. polygons) must first be 'aligned':
+ # As per Boost requirements, points (e.g. vertical polygon) must be 'aligned':
# - first rotated/tilted as to lay flat along XY plane (Z-axis ~= 0)
- # - initial Z-axis values are represented as Y-axis values
+ # - initial Z-axis values now become Y-axis values
# - points with the lowest X-axis values are 'aligned' along X-axis (0)
# - points with the lowest Z-axis values are 'aligned' along Y-axis (0)
# - for several Boost methods, points must be clockwise in sequence
#
# Check OSut's poly() method, which offers such Boost-related options.
@@ -2943,13 +2997,20 @@
# 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
return nil unless xab.cross(xa1b1).length.round(4) < TOL2
return nil unless xab.cross(xa1b2).length.round(4) < TOL2
+ # Reset.
+ xa1b1 = a.cross(a1b1)
+ xa1b2 = a.cross(a1b2)
+
# Both segment endpoints can't be 'behind' point.
return nil if a.dot(a1b1) < 0 && a.dot(a1b2) < 0
# Both in 'front' of point? Pick farthest from 'a'.
if a.dot(a1b1) > 0 && a.dot(a1b2) > 0
@@ -2999,24 +3060,27 @@
false
end
##
- # Determines if pre-'aligned' OpenStudio 3D points are listed clockwise.
+ # Validates whether OpenStudio 3D points are listed clockwise, assuming points
+ # have been pre-'aligned' - not just flattened along XY (i.e. Z = 0).
#
- # @param pts [OpenStudio::Point3dVector] 3D points
+ # @param pts [OpenStudio::Point3dVector] pre-aligned 3D points
#
# @return [Bool] whether sequence is clockwise
# @return [false] if invalid input (see logs)
def clockwise?(pts = nil)
mth = "OSut::#{__callee__}"
pts = to_p3Dv(pts)
- n = false
- return invalid("3+ points" , mth, 1, DBG, n) if pts.size < 3
- return invalid("flat points", mth, 1, DBG, n) unless xyz?(pts, :z)
+ return invalid("3+ points" , mth, 1, DBG, false) if pts.size < 3
+ return invalid("flat points", mth, 1, DBG, false) unless xyz?(pts, :z)
- OpenStudio.pointInPolygon(pts.first, pts, TOL)
+ n = OpenStudio.getOutwardNormal(pts)
+ return invalid("polygon", mth, 1, DBG, false) if n.empty?
+
+ n.get.z > 0 ? false : true
end
##
# Returns OpenStudio 3D points (min 3x) conforming to an UpperLeftCorner (ULC)
# convention. Points Z-axis values must be ~= 0. Points are returned
@@ -3142,12 +3206,12 @@
##
# 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)
- # counterclockwise sequence, or in clockwise sequence.
+ # Returned vector can also be 'aligned', as well as in UpperLeftCorner (ULC),
+ # BottomLeftCorner (BLC), in clockwise (or counterclockwise) sequence.
#
# @param pts [Set<OpenStudio::Point3d>] 3D points
# @param vx [Bool] whether to check for convexity
# @param uq [Bool] whether to ensure uniqueness
# @param co [Bool] whether to ensure non-collinearity
@@ -3160,11 +3224,11 @@
pts = to_p3Dv(pts)
cl = OpenStudio::Transformation
v = OpenStudio::Point3dVector.new
vx = false unless [true, false].include?(vx)
uq = false unless [true, false].include?(uq)
- co = true unless [true, false].include?(co)
+ co = false unless [true, false].include?(co)
# --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- #
# Exit if mismatched/invalid arguments.
ok1 = tt == true || tt == false || tt.is_a?(cl)
ok2 = sq == :no || sq == :ulc || sq == :blc || sq == :cw
@@ -3172,11 +3236,11 @@
return invalid("sequence", mth, 6, DBG, v) unless ok2
# --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- #
# Minimum 3 points?
p3 = getNonCollinears(pts, 3)
- return empty("polygon", mth, ERR, v) if p3.size < 3
+ return empty("polygon (non-collinears < 3)", mth, ERR, v) if p3.size < 3
# Coplanar?
pln = OpenStudio::Plane.new(p3)
pts.each do |pt|
@@ -3271,11 +3335,11 @@
s = poly(s, false, true, true)
return empty("polygon", mth, DBG, false) if s.empty?
return mismatch("point", p0, cl, mth, DBG, false) unless p0.is_a?(cl)
n = OpenStudio.getOutwardNormal(s)
- return false if n.empty?
+ return invalid("plane/normal", mth, 2, DBG, false) if n.empty?
n = n.get
pl = OpenStudio::Plane.new(s.first, n)
return false unless pl.pointOnPlane(p0)
@@ -3297,27 +3361,25 @@
# - return false if number of intersections is even
mid = midpoint(segment.first, segment.last)
mpV = scalar(mid - p0, 1000)
p1 = p0 + mpV
ctr = 0
- pts = []
# Skip if ~collinear.
- next if (mpV.cross(segment.last - segment.first).length).round(4) < TOL2
+ next if mpV.cross(segment.last - segment.first).length.round(4) < TOL2
segments.each do |sg|
intersect = getLineIntersection([p0, p1], sg)
next unless intersect
- # One of the polygon vertices?
+ # Skip test altogether if one of the polygon vertices.
if holds?(s, intersect)
- next if holds?(pts, intersect)
-
- pts << intersect
+ ctr = 0
+ break
+ else
+ ctr += 1
end
-
- ctr += 1
end
next if ctr.zero?
return false if ctr.even?
end
@@ -3332,60 +3394,92 @@
# @param p2 [Set<OpenStudio::Point3d>] 2nd set of 3D points
#
# @return [Bool] whether 2 polygons are parallel
# @return [false] if invalid input (see logs)
def parallel?(p1 = nil, p2 = nil)
- p1 = poly(p1, false, true, false)
- p2 = poly(p2, false, true, false)
+ p1 = poly(p1, false, true)
+ p2 = poly(p2, false, true)
return false if p1.empty?
return false if p2.empty?
- p1 = getNonCollinears(p1, 3)
- p2 = getNonCollinears(p2, 3)
- return false if p1.empty?
- return false if p2.empty?
+ n1 = OpenStudio.getOutwardNormal(p1)
+ n2 = OpenStudio.getOutwardNormal(p2)
+ return false if n1.empty?
+ return false if n2.empty?
- pl1 = OpenStudio::Plane.new(p1)
- pl2 = OpenStudio::Plane.new(p2)
+ n1.get.dot(n2.get).abs > 0.99
+ end
- pl1.outwardNormal.dot(pl2.outwardNormal).abs > 0.99
+ ##
+ # 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
+ #
+ # @param pts [Set<OpenStudio::Point3d>] OpenStudio 3D points
+ #
+ # @return [Bool] if considered a roof surface
+ # @return [false] if invalid input (see logs)
+ def roof?(pts = nil)
+ ray = OpenStudio::Point3d.new(0,0,1) - OpenStudio::Point3d.new(0,0,0)
+ dut = Math.cos(60 * Math::PI / 180)
+ pts = poly(pts, false, true, true)
+ return false if pts.empty?
+
+ dot = ray.dot(OpenStudio.getOutwardNormal(pts).get)
+ return false if dot.round(2) <= 0
+ return true if dot.round(2) == 1
+
+ dot.round(4) >= dut.round(4)
end
##
- # Validates whether a polygon faces upwards.
+ # Validates whether a polygon faces upwards, harmonized with OpenStudio
+ # Utilities' "alignZPrime" function.
#
# @param pts [Set<OpenStudio::Point3d>] OpenStudio 3D points
#
# @return [Bool] if facing upwards
# @return [false] if invalid input (see logs)
def facingUp?(pts = nil)
- up = OpenStudio::Point3d.new(0,0,1) - OpenStudio::Point3d.new(0,0,0)
- pts = poly(pts, false, true, false)
+ ray = OpenStudio::Point3d.new(0,0,1) - OpenStudio::Point3d.new(0,0,0)
+ pts = poly(pts, false, true, true)
return false if pts.empty?
- pts = getNonCollinears(pts, 3)
- return false if pts.empty?
-
- OpenStudio::Plane.new(pts).outwardNormal.dot(up) > 0.99
+ OpenStudio.getOutwardNormal(pts).get.dot(ray) > 0.99
end
##
- # Validates whether a polygon faces downwards.
+ # Validates whether a polygon faces downwards, harmonized with OpenStudio
+ # Utilities' "alignZPrime" function.
#
# @param pts [Set<OpenStudio::Point3d>] OpenStudio 3D points
#
# @return [Bool] if facing downwards
# @return [false] if invalid input (see logs)
def facingDown?(pts = nil)
- lo = OpenStudio::Point3d.new(0,0,-1) - OpenStudio::Point3d.new(0,0,0)
- pts = poly(pts, false, true, false)
+ ray = OpenStudio::Point3d.new(0,0,-1) - OpenStudio::Point3d.new(0,0,0)
+ pts = poly(pts, false, true, true)
return false if pts.empty?
- pts = getNonCollinears(pts, 3)
+ OpenStudio.getOutwardNormal(pts).get.dot(ray) > 0.99
+ end
+
+ ##
+ # Validates whether surface can be considered 'sloped' (i.e. not ~flat, as per
+ # OpenStudio Utilities' "alignZPrime"). A vertical polygon returns true.
+ #
+ # @param pts [Set<OpenStudio::Point3d>] OpenStudio 3D points
+ #
+ # @return [Bool] whether surface is sloped
+ # @return [false] if invalid input (see logs)
+ def sloped?(pts = nil)
+ mth = "OSut::#{__callee__}"
+ pts = poly(pts, false, true, true)
return false if pts.empty?
+ return false if facingUp?(pts)
+ return false if facingDown?(pts)
- OpenStudio::Plane.new(pts).outwardNormal.dot(lo) > 0.99
+ true
end
##
# Validates whether an OpenStudio polygon is a rectangle (4x sides + 2x
# diagonals of equal length, meeting at midpoints).
@@ -3452,10 +3546,19 @@
return false if p1.empty?
return false if p2.empty?
p1.each { |p0| return false unless pointWithinPolygon?(p0, p2) }
+ # Although p2 points may lie ALONG p1, none may lie entirely WITHIN p1.
+ p2.each { |p0| return false if pointWithinPolygon?(p0, p1, true) }
+
+ # p1 segment mid-points must not lie OUTSIDE of p2.
+ getSegments(p1).each do |sg|
+ mp = midpoint(sg.first, sg.last)
+ return false unless pointWithinPolygon?(mp, p2)
+ end
+
entirely = false unless [true, false].include?(entirely)
return true unless entirely
p1.each { |p0| return false unless pointWithinPolygon?(p0, p2, entirely) }
@@ -3598,11 +3701,11 @@
# 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.first
- pl = OpenStudio::Plane.new(getNonCollinears(p2, 3))
+ pl = OpenStudio::Plane.new(p2)
n = pl.outwardNormal
return face if n.dot(ray).abs < TOL
p1.each do |pt|
length = n.dot(pt - p0) / n.dot(ray.reverseVector)
@@ -3960,10 +4063,12 @@
box << OpenStudio::Point3d.new(p0.x, p0.y, p0.z)
box << OpenStudio::Point3d.new(p1.x, p1.y, p1.z)
box << OpenStudio::Point3d.new(p2.x, p2.y, p2.z)
box << OpenStudio::Point3d.new(p3.x, p3.y, p3.z)
+ box = getNonCollinears(box, 4)
+ return bkp unless box.size == 4
box = blc(box)
return bkp unless rectangular?(box)
box = to_p3Dv(t * box) if t
@@ -4005,10 +4110,13 @@
# Generate medial bounded box.
box << plane.project(mpoints.first)
box << mpoints.first
box << mpoints.last
box << plane.project(mpoints.last)
+ box = getNonCollinears(box).to_a
+ return bkp unless box.size == 4
+
box = clockwise?(box) ? blc(box.reverse) : blc(box)
return bkp unless rectangular?(box)
return bkp unless fits?(box, pts)
box = to_p3Dv(t * box) if t
@@ -4069,10 +4177,11 @@
next if same?(p2, sg.first)
out = triadBox(OpenStudio::Point3dVector.new([m0, p1, p2]))
next if out.empty?
next unless fits?(out, pts)
+ next if fits?(pts, out)
area = OpenStudio.getArea(out)
next if area.empty?
area = area.get
@@ -4094,10 +4203,11 @@
next if same?(p2, p1)
out = triadBox(OpenStudio::Point3dVector.new([p0, p1, p2]))
next if out.empty?
next unless fits?(out, pts)
+ next if fits?(pts, out)
area = OpenStudio.getArea(out)
next if area.empty?
area = area.get
@@ -4126,10 +4236,11 @@
next if same?(p2, p1)
out = medialBox(OpenStudio::Point3dVector.new([p0, p1, p2]))
next if out.empty?
next unless fits?(out, pts)
+ next if fits?(pts, out)
area = OpenStudio.getArea(box)
next if area.empty?
area = area.get
@@ -4155,10 +4266,11 @@
p2 = sg[2]
out = medialBox(OpenStudio::Point3dVector.new([p0, p1, p2]))
next if out.empty?
next unless fits?(out, pts)
+ next if fits?(pts, out)
area = OpenStudio.getArea(box)
next if area.empty?
area = area.get
@@ -4189,10 +4301,11 @@
next if same?(p2, p1)
out = medialBox(OpenStudio::Point3dVector.new([p0, p1, p2]))
next if out.empty?
next unless fits?(out, pts)
+ next if fits?(pts, out)
area = OpenStudio.getArea(out)
next if area.empty?
area = area.get
@@ -4212,36 +4325,47 @@
box
end
##
# Generates re-'aligned' polygon vertices wrt main axis of symmetry of its
- # largest bounded box. A Hash is returned with 6x key:value pairs ...
+ # largest bounded box. Input polygon vertex Z-axis values must equal 0, and be
+ # counterclockwise. A Hash is returned with 6x key:value pairs ...
# set: realigned (cloned) polygon vertices, box: its bounded box (wrt to :set),
# bbox: its bounding box, t: its translation transformation, r: its rotation
# transformation, and o: the origin coordinates of its axis of rotation. First,
# cloned polygon vertices are rotated so the longest axis of symmetry of its
# bounded box lies parallel to the X-axis; :o being the midpoint of the narrow
- # side (of the bounded box) nearest to grid origin (0,0,0). Once rotated,
+ # 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 == 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
+ # 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.
#
# @param pts [Set<OpenStudio::Point3d>] OpenStudio 3D points
+ # @param force [Bool] whether to force rotation for aligned yet narrow boxes
#
# @return [Hash] :set, :box, :bbox, :t, :r & :o
# @return [Hash] :set, :box, :bbox, :t, :r & :o (nil) if invalid (see logs)
- def getRealignedFace(pts = nil)
+ def getRealignedFace(pts = nil, force = false)
mth = "OSut::#{__callee__}"
out = { set: nil, box: nil, bbox: nil, t: nil, r: nil, o: nil }
pts = poly(pts, false, true)
return out if pts.empty?
return invalid("aligned plane", mth, 1, DBG, out) unless xyz?(pts, :z)
return invalid("clockwise pts", mth, 1, DBG, out) if clockwise?(pts)
+ # Optionally force rotation so bounded box ends up wider than taller.
+ # Strongly suggested for flat surfaces like roofs (see 'sloped?').
+ unless [true, false].include?(force)
+ log(DBG, "Ignoring force input (#{mth})")
+ force = false
+ end
+
o = OpenStudio::Point3d.new(0, 0, 0)
w = width(pts)
h = height(pts)
d = h > w ? h : w
sgs = {}
@@ -4261,11 +4385,10 @@
end
sgs = sgs.sort_by { |sg, s| s[:mo] }.first(2).to_h if square?(box)
sgs = sgs.sort_by { |sg, s| s[:l ] }.first(2).to_h unless square?(box)
sgs = sgs.sort_by { |sg, s| s[:mo] }.first(2).to_h unless square?(box)
-
sg0 = sgs.values[0]
sg1 = sgs.values[1]
if (sg0[:mo]).round(2) == (sg1[:mo]).round(2)
i = sg1[:mid].y.round(2) < sg0[:mid].y.round(2) ? sg1[:idx] : sg0[:idx]
@@ -4281,25 +4404,32 @@
right = OpenStudio::Point3d.new(origin.x + d, origin.y , 0) - origin
north = OpenStudio::Point3d.new(origin.x, origin.y + d, 0) - origin
axis = OpenStudio::Point3d.new(origin.x, origin.y , d) - origin
angle = OpenStudio::getAngle(right, seg)
angle = -angle if north.dot(seg) < 0
+
+ # Skip rotation if bounded box is already aligned along XY grid (albeit
+ # 'narrow'), i.e. if the angle is 90°.
+ if angle.round(3) == (Math::PI/2).round(3)
+ angle = 0 unless force
+ end
+
r = OpenStudio.createRotation(origin, axis, angle)
pts = to_p3Dv(r.inverse * pts)
box = to_p3Dv(r.inverse * box)
dX = pts.min_by(&:x).x
dY = pts.min_by(&:y).y
xy = OpenStudio::Point3d.new(origin.x + dX, origin.y + dY, 0)
origin2 = xy - origin
t = OpenStudio.createTranslation(origin2)
- set = t.inverse * pts
- box = t.inverse * box
+ set = to_p3Dv(t.inverse * pts)
+ box = to_p3Dv(t.inverse * box)
bbox = outline([set])
- out[:set ] = set
- out[:box ] = box
- out[:bbox] = bbox
+ out[:set ] = blc(set)
+ out[:box ] = blc(box)
+ out[:bbox] = blc(bbox)
out[:t ] = t
out[:r ] = r
out[:o ] = origin
out
@@ -4307,78 +4437,105 @@
##
# Returns 'width' of a set of OpenStudio 3D points, once re/aligned.
#
# @param pts [Set<OpenStudio::Point3d>] 3D points, once re/aligned
+ # @param force [Bool] whether to force rotation of (narrow) bounded box
#
- # @return [Float] width along X-axis, once re/aligned
+ # @return [Float] width al©ong X-axis, once re/aligned
# @return [0.0] if invalid inputs
- def alignedWidth(pts = nil)
+ def alignedWidth(pts = nil, force = false)
+ mth = "OSut::#{__callee__}"
pts = poly(pts, false, true, true, true)
return 0 if pts.size < 2
- pts = getRealignedFace(pts)[:set]
+ unless [true, false].include?(force)
+ log(DBG, "Ignoring force input (#{mth})")
+ force = false
+ end
+
+ pts = getRealignedFace(pts, force)[:set]
return 0 if pts.size < 2
pts.max_by(&:x).x - pts.min_by(&:x).x
end
##
# Returns 'height' of a set of OpenStudio 3D points, once re/aligned.
#
# @param pts [Set<OpenStudio::Point3d>] 3D points, once re/aligned
+ # @param force [Bool] whether to force rotation of (narrow) bounded box
#
# @return [Float] height along Y-axis, once re/aligned
# @return [0.0] if invalid inputs
- def alignedHeight(pts = nil)
- pts = pts = poly(pts, false, true, true, true)
+ def alignedHeight(pts = nil, force = false)
+ mth = "OSut::#{__callee__}"
+ pts = poly(pts, false, true, true, true)
return 0 if pts.size < 2
- pts = getRealignedFace(pts)[:set]
+ unless [true, false].include?(force)
+ log(DBG, "Ignoring force input (#{mth})")
+ force = false
+ end
+
+ pts = getRealignedFace(pts, force)[:set]
return 0 if pts.size < 2
pts.max_by(&:y).y - pts.min_by(&:y).y
end
##
- # Generates leader line anchors, linking polygon vertices to one or more sets
- # (Hashes) of sequenced vertices. By default, the method seeks to link set
- # :vtx (key) vertices (users can select another collection of vertices, e.g.
- # tag == :box). The method minimally validates individual sets of vertices
- # (e.g. coplanarity, non-self-intersecting, no inter-set conflicts). Potential
- # leader lines cannot intersect each other, other 'tagged' set vertices or
- # original polygon edges. For highly-articulated cases (e.g. a narrow polygon
- # with multiple concavities, holding multiple sets), such leader line
- # conflicts will surely occur. The method relies on a 'first-come-first-served'
- # approach: sets without leader lines are ignored (check for set :void keys,
- # see error logs). It is recommended to sort sets prior to calling the method.
+ # 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. 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.
#
- # @param s [Set<OpenStudio::Point3d>] a larger (parent) set of points
- # @param [Array<Hash>] set a collection of sequenced vertices
- # @option [Symbol] tag sequence of set vertices to target
+ # @param s [Set<OpenStudio::Point3d>] a (larger) parent set of points
+ # @param [Array<Hash>] set a collection of (smaller) sequenced points
+ # @option [Symbol] tag sequence of subset vertices to target
#
- # @return [Integer] number of successfully-generated anchors (check logs)
- def genAnchors(s = nil, set = [], tag = :vtx)
+ # @return [Integer] number of successfully anchored subsets (see logs)
+ def genAnchors(s = nil, set = [], tag = :box)
mth = "OSut::#{__callee__}"
- dZ = nil
- t = nil
+ n = 0
id = s.respond_to?(:nameString) ? "#{s.nameString}: " : ""
pts = poly(s)
- n = 0
- return n if pts.empty?
+ return invalid("#{id} polygon", mth, 1, DBG, n) if pts.empty?
return mismatch("set", set, Array, mth, DBG, n) unless set.respond_to?(:to_a)
- set = set.to_a
+ origin = OpenStudio::Point3d.new(0,0,0)
+ zenith = OpenStudio::Point3d.new(0,0,1)
+ ray = zenith - origin
+ set = set.to_a
- # Validate individual sets. Purge surface-specific leader line anchors.
+ # Validate individual subsets. Purge surface-specific leader line anchors.
set.each_with_index do |st, i|
- str1 = id + "set ##{i+1}"
+ str1 = id + "subset ##{i+1}"
str2 = str1 + " #{tag.to_s}"
return mismatch(str1, st, Hash, mth, DBG, n) unless st.respond_to?(:key?)
return hashkey( str1, st, tag, mth, DBG, n) unless st.key?(tag)
return empty("#{str2} vertices", mth, DBG, n) if st[tag].empty?
+ if st.key?(:out)
+ return hashkey( str1, st, :t, mth, DBG, n) unless st.key?(:t)
+ return hashkey( str1, st, :ti, mth, DBG, n) unless st.key?(:ti)
+ return hashkey( str1, st, :t0, mth, DBG, n) unless st.key?(:t0)
+ end
+
stt = poly(st[tag])
return invalid("#{str2} polygon", mth, 0, DBG, n) if stt.empty?
return invalid("#{str2} gap", mth, 0, DBG, n) unless fits?(stt, pts, true)
if st.key?(:ld)
@@ -4389,120 +4546,128 @@
else
st[:ld] = {}
end
end
- if facingUp?(pts)
- if xyz?(pts, :z)
- dZ = 0
+ set.each_with_index do |st, i|
+ # When a subset already holds a leader line anchor (from an initial call
+ # to 'genAnchors'), it inherits key :out - a Hash holding (among others) a
+ # 'realigned' set of points (by default a 'realigned' :box). The latter is
+ # typically generated from an outdoor-facing roof (e.g. when called from
+ # 'lights'). Subsequent calls to 'genAnchors' may send (as first
+ # argument) a corresponding ceiling tile below (also 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 st.key?(:out)
+ 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
- dZ = pts.first.z
- pts = flatten(pts).to_a
+ st[:t] = OpenStudio::Transformation.alignFace(pts) unless st.key?(:t)
+ tpts = st[:t].inverse * st[tag]
+ o = getRealignedFace(tpts, true)
+ tpts = st[:t] * (o[:r] * (o[:t] * o[:set]))
+
+ st[:out] = o
+ st[tag ] = tpts
end
- else
- t = OpenStudio::Transformation.alignFace(pts)
- pts = t.inverse * pts
end
- # Set leader lines anchors. Gather candidate leader line anchors; select
- # anchor with shortest distance to first vertex of 'tagged' set.
+ # Identify candidate leader line anchors for each subset.
set.each_with_index do |st, i|
candidates = []
- break if st[:ld].key?(s)
+ tpts = st[tag]
- stt = dZ ? flatten(st[tag]).to_a : t.inverse * st[tag]
- p1 = stt.first
+ pts.each do |pt|
+ ld = [pt, tpts.first]
+ nb = 0
- pts.each_with_index do |pt, k|
- ld = [pt, p1]
- nb = 0
-
- # Check for intersections between leader line and polygon edges.
+ # Check for intersections between leader line and larger polygon edges.
getSegments(pts).each do |sg|
break unless nb.zero?
next if holds?(sg, pt)
nb += 1 if lineIntersects?(sg, ld)
end
- next unless nb.zero?
-
- # Check for intersections between candidate leader line and other sets.
- set.each_with_index do |other, j|
+ # Check for intersections between candidate leader line vs other subsets.
+ set.each do |other|
break unless nb.zero?
- next if i == j
+ next if st == other
- ost = dZ ? flatten(other[tag]).to_a : t.inverse * other[tag]
- sgj = getSegments(ost)
+ ost = other[tag]
- sgj.each { |sg| nb += 1 if lineIntersects?(ld, sg) }
+ getSegments(ost).each { |sg| nb += 1 if lineIntersects?(ld, sg) }
end
- next unless nb.zero?
-
# ... and previous leader lines (first come, first serve basis).
- set.each_with_index do |other, j|
+ set.each do |other|
break unless nb.zero?
- next if i == j
+ next if st == other
+ next unless other.key?(:ld)
next unless other[:ld].key?(s)
ost = other[tag]
- pj = ost.first
- old = other[:ld][s]
- ldj = dZ ? flatten([ old, pj ]) : t.inverse * [ old, pj ]
+ pld = other[:ld][s]
+ next if same?(pld, pt)
- unless same?(old, pt)
- nb += 1 if lineIntersects?(ld, ldj)
- end
+ nb += 1 if lineIntersects?(ld, [pld, ost.first])
end
- next unless nb.zero?
-
# Finally, check for self-intersections.
- getSegments(stt).each do |sg|
+ getSegments(tpts).each do |sg|
break unless nb.zero?
- next if holds?(sg, p1)
+ next if holds?(sg, tpts.first)
nb += 1 if lineIntersects?(sg, ld)
nb += 1 if (sg.first - sg.last).cross(ld.first - ld.last).length < TOL
end
candidates << pt if nb.zero?
end
if candidates.empty?
str = id + "set ##{i+1}"
- log(ERR, "#{str}: unable to anchor #{tag} leader line (#{mth})")
+ log(WRN, "#{str}: unable to anchor #{tag} leader line (#{mth})")
st[:void] = true
else
- p0 = candidates.sort_by! { |pt| (pt - p1).length }.first
-
- if dZ
- st[:ld][s] = OpenStudio::Point3d.new(p0.x, p0.y, p0.z + dZ)
- else
- st[:ld][s] = t * p0
- end
-
+ p0 = candidates.sort_by { |pt| (pt - tpts.first).length }.first
n += 1
+
+ st[:ld][s] = p0
end
end
n
end
##
- # Generates extended polygon vertices to circumscribe one or more sets
- # (Hashes) of sequenced vertices. The method minimally validates individual
- # sets of vertices (e.g. coplanarity, non-self-intersecting, no inter-set
- # conflicts). Valid leader line anchors (set key :ld) need to be generated
- # prior to calling the method (see genAnchors). By default, the method seeks
- # to link leader line anchors to set :vtx (key) vertices (users can select
- # another collection of vertices, e.g. tag == :box).
+ # 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).
#
# @param s [Set<OpenStudio::Point3d>] a larger (parent) set of points
- # @param [Array<Hash>] set a collection of sequenced vertices
- # @option set [Hash] :ld a collection of polygon-specific leader line anchors
+ # @param [Array<Hash>] set a collection of (smaller) sequenced vertices
+ # @option set [Hash] :ld a polygon-specific leader line anchors
# @option [Symbol] tag sequence of set vertices to target
#
# @return [OpenStudio::Point3dVector] extended vertices (see logs if empty)
def genExtendedVertices(s = nil, set = [], tag = :vtx)
mth = "OSut::#{__callee__}"
@@ -4517,12 +4682,13 @@
set = set.to_a
# Validate individual sets.
set.each_with_index do |st, i|
- str1 = id + "set ##{i+1}"
+ str1 = id + "subset ##{i+1}"
str2 = str1 + " #{tag.to_s}"
+ next if st.key?(:void) && st[:void]
return mismatch(str1, st, Hash, mth, DBG, a) unless st.respond_to?(:key?)
return hashkey( str1, st, tag, mth, DBG, a) unless st.key?(tag)
return empty("#{str2} vertices", mth, DBG, a) if st[tag].empty?
return hashkey( str1, st, :ld, mth, DBG, a) unless st.key?(:ld)
@@ -4538,11 +4704,12 @@
# Re-sequence polygon vertices.
pts.each do |pt|
v << pt
# Loop through each valid set; concatenate circumscribing vertices.
- set.each_with_index do |st, i|
+ set.each do |st|
+ next if st.key?(:void) && st[:void]
next unless same?(st[:ld][s], pt)
next unless st.key?(tag)
v += st[tag].to_a
v << pt
@@ -4551,25 +4718,26 @@
to_p3Dv(v)
end
##
- # Generates arrays of rectangular polygon inserts within a larger polygon. If
- # successful, each set inherits additional key:value pairs: namely :vtx
- # (subset of polygon circumscribing vertices), and :vts (collection of
- # indivudual polygon insert vertices). Valid leader line anchors (set key :ld)
- # need to be generated prior to calling the method (see genAnchors, and
- # genExtendedvertices).
+ # 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'.
#
- # @param s [Set<OpenStudio::Point3d>] a larger polygon
- # @param [Array<Hash>] set a collection of polygon insert instructions
- # @option set [Set<OpenStudio::Point3d>] :box bounding box of each collection
- # @option set [Hash] :ld a collection of polygon-specific leader line anchors
+ # @param s [Set<OpenStudio::Point3d>] a larger (parent) set of points
+ # @param [Array<Hash>] set a collection of (smaller) sequenced vertices
+ # @option set [Set<OpenStudio::Point3d>] :box bounding box of each subset
+ # @option set [Hash] :ld a collection of leader line anchors
# @option set [Integer] :rows (1) number of rows of inserts
# @option set [Integer] :cols (1) number of columns of inserts
- # @option set [Numeric] :w0 (1.4) width of individual inserts (wrt cols) min 0.4
- # @option set [Numeric] :d0 (1.4) depth of individual inserts (wrt rows) min 0.4
+ # @option set [Numeric] :w0 width of individual inserts (wrt cols) min 0.4
+ # @option set [Numeric] :d0 depth of individual inserts (wrt rows) min 0.4
# @option set [Numeric] :dX (0) optional left/right X-axis buffer
# @option set [Numeric] :dY (0) optional top/bottom Y-axis buffer
#
# @return [OpenStudio::Point3dVector] new polygon vertices (see logs if empty)
def genInserts(s = nil, set = [])
@@ -4585,23 +4753,25 @@
gap = 0.1
gap4 = 0.4 # minimum insert width/depth
# Validate/reset individual set collections.
set.each_with_index do |st, i|
- str1 = id + "set ##{i+1}"
+ str1 = id + "subset ##{i+1}"
+ next if st.key?(:void) && st[:void]
return mismatch(str1, st, Hash, mth, DBG, a) unless st.respond_to?(:key?)
return hashkey( str1, st, :box, mth, DBG, a) unless st.key?(:box)
return hashkey( str1, st, :ld, mth, DBG, a) unless st.key?(:ld)
+ return hashkey( str1, st, :out, mth, DBG, a) unless st.key?(:out)
str2 = str1 + " anchor"
ld = st[:ld]
return mismatch(str2, ld, Hash, mth, DBG, a) unless ld.respond_to?(:key?)
return hashkey( str2, ld, s, mth, DBG, a) unless ld.key?(s)
return mismatch(str2, ld[s], cl, mth, DBG, a) unless ld[s].is_a?(cl)
# Ensure each set bounding box is safely within larger polygon boundaries.
- # TO DO: In line with related addSkylights "TO DO", expand method to
+ # @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])
return invalid(str3, mth, 0, DBG, a) if bx.empty?
@@ -4657,13 +4827,14 @@
else
st[:dY] = nil
end
end
- # Flag conflicts between set bounding boxes. TO DO: ease up for ridges.
+ # Flag conflicts between set bounding boxes. @todo: ease up for ridges.
set.each_with_index do |st, i|
bx = st[:box]
+ next if st.key?(:void) && st[:void]
set.each_with_index do |other, j|
next if i == j
bx2 = other[:box]
@@ -4671,50 +4842,31 @@
next unless overlaps?(bx, bx2)
return invalid("#{str4} (overlapping)", mth, 0, DBG, a)
end
end
- # Loop through each 'valid' set (i.e. linking a valid leader line anchor),
- # generate set vertex array based on user-provided specs. Reset BLC vertex
- # coordinates once completed.
+ 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.
set.each_with_index do |st, i|
- str = id + "set ##{i+1}"
- dZ = nil
- t = nil
- bx = st[:box]
+ str = id + "subset ##{i+1}"
+ next if st.key?(:void) && st[:void]
- if facingUp?(bx)
- if xyz?(bx, :z)
- dZ = 0
- else
- dZ = bx.first.z
- bx = flatten(bx).to_a
- end
- else
- t = OpenStudio::Transformation.alignFace(bx)
- bx = t.inverse * bx
- end
-
- o = getRealignedFace(bx)
- next unless o[:set]
-
- st[:out] = o
- st[:bx ] = blc(o[:r] * (o[:t] * o[:set]))
-
-
+ 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 indivual 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
@@ -4788,20 +4940,12 @@
vec << OpenStudio::Point3d.new(xC , yC - y, 0)
vec << OpenStudio::Point3d.new(xC + x, yC - y, 0)
vec << OpenStudio::Point3d.new(xC + x, yC , 0)
# Store.
- vtz = ulc(o[:r] * (o[:t] * vec))
+ vts[nom] = to_p3Dv(t * ulc(o[:r] * (o[:t] * vec)))
- if dZ
- vz = OpenStudio::Point3dVector.new
- vtz.each { |v| vz << OpenStudio::Point3d.new(v.x, v.y, v.z + dZ) }
- vts[nom] = vz
- else
- vts[nom] = to_p3Dv(t * vtz)
- end
-
# Add reverse vertices, circumscribing each insert.
vec.reverse!
vec.pop if iX == cols - 1
vtx += vec
@@ -4812,42 +4956,34 @@
yC -= gY + y
vtx << OpenStudio::Point3d.new(xC, yC, 0)
end
end
- vtx = o[:r] * (o[:t] * vtx)
-
- if dZ
- vz = OpenStudio::Point3dVector.new
- vtx.each { |v| vz << OpenStudio::Point3d.new(v.x, v.y, v.z + dZ) }
- vtx = vz
- else
- vtx = to_p3Dv(t * vtx)
- end
-
st[:vts] = vts
- st[:vtx] = vtx
+ st[:vtx] = to_p3Dv(t * (o[:r] * (o[:t] * vtx)))
end
# Extended vertex sequence of the larger polygon.
genExtendedVertices(s, set)
end
##
# Returns an array of OpenStudio space surfaces or subsurfaces that match
# criteria, e.g. exterior, north-east facing walls in hotel "lobby". Note that
- # 'sides' rely on space coordinates (not absolute model coordinates). Also,
+ # 'sides' rely on space coordinates (not building or site coordinates). Also,
# 'sides' are exclusive (not inclusive), e.g. walls strictly north-facing or
# strictly east-facing 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".
#
# @param spaces [Set<OpenStudio::Model::Space>] target spaces
# @param boundary [#to_s] OpenStudio outside boundary condition
# @param type [#to_s] OpenStudio surface (or subsurface) type
# @param sides [Set<Symbols>] direction keys, e.g. :north (see OSut::SIDZ)
#
# @return [Array<OpenStudio::Model::Surface>] surfaces (may be empty, no logs)
- def facets(spaces = [], boundary = "Outdoors", type = "Wall", sides = [])
+ def facets(spaces = [], boundary = "all", type = "all", sides = [])
spaces = spaces.is_a?(OpenStudio::Model::Space) ? [spaces] : spaces
spaces = spaces.respond_to?(:to_a) ? spaces.to_a : []
return [] if spaces.empty?
sides = sides.respond_to?(:to_sym) ? [sides] : sides
@@ -4857,24 +4993,29 @@
boundary = trim(boundary).downcase
type = trim(type).downcase
return [] if boundary.empty?
return [] if type.empty?
- # Filter sides. If sides is initially empty, return all surfaces of matching
- # type and outside boundary condition.
+ # Filter sides. If 'sides' is initially empty, return all surfaces of
+ # matching type and outside boundary condition.
unless sides.empty?
sides = sides.select { |side| SIDZ.include?(side) }
return [] if sides.empty?
end
spaces.each do |space|
return [] unless space.respond_to?(:setSpaceType)
space.surfaces.each do |s|
- next unless s.outsideBoundaryCondition.downcase == boundary
- next unless s.surfaceType.downcase == type
+ unless boundary == "all"
+ next unless s.outsideBoundaryCondition.downcase == boundary
+ end
+ unless type == "all"
+ next unless s.surfaceType.downcase == type
+ end
+
if sides.empty?
faces << s
else
orientations = []
orientations << :top if s.outwardNormal.z > TOL
@@ -4889,17 +5030,19 @@
end
end
# SubSurfaces?
spaces.each do |space|
- break unless faces.empty?
-
space.surfaces.each do |s|
- next unless s.outsideBoundaryCondition.downcase == boundary
+ unless boundary == "all"
+ next unless s.outsideBoundaryCondition.downcase == boundary
+ end
s.subSurfaces.each do |sub|
- next unless sub.subSurfaceType.downcase == type
+ unless type == "all"
+ next unless sub.subSurfaceType.downcase == type
+ end
if sides.empty?
faces << sub
else
orientations = []
@@ -5010,11 +5153,13 @@
##
# 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.
+ # 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 'roof?'.
#
# @param spaces [Set<OpenStudio::Model::Space>] target spaces
#
# @return [Array<OpenStudio::Model::Surface>] roofs (may be empty)
def getRoofs(spaces = [])
@@ -5026,16 +5171,16 @@
spaces = spaces.select { |space| space.is_a?(OpenStudio::Model::Space) }
# Space-specific outdoor-facing roof surfaces.
roofs = facets(spaces, "Outdoors", "RoofCeiling")
+ roofs = roofs.select { |roof| roof?(roof) }
- # Outdoor-facing roof surfaces of unoccupied plenums or attics above?
spaces.each do |space|
- # When multiple spaces are involved (e.g. plenums, attics), the target
+ # 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 local transformation.
+ # Fetching site transformation.
t0 = transforms(space)
next unless t0[:t]
t0 = t0[:t]
@@ -5054,12 +5199,14 @@
ti = transforms(other)
next unless ti[:t]
ti = ti[:t]
- # TO DO: recursive call for stacked spaces as atria (via AirBoundaries).
+ # @todo: recursive call for stacked spaces as atria (via AirBoundaries).
facets(other, "Outdoors", "RoofCeiling").each do |ruf|
+ next unless roof?(ruf)
+
rvi = ti * ruf.vertices
cst = cast(cv0, rvi, up)
next unless overlaps?(cst, rvi, false)
roofs << ruf unless roofs.include?(ruf)
@@ -5126,15 +5273,17 @@
# @option subs [#to_f] :offset left-right centreline dX e.g. between doors
# @option subs [#to_f] :centreline left-right dX (sub/array vs base)
# @option subs [#to_f] :r_buffer gap between sub/array and right corner
# @option subs [#to_f] :l_buffer gap between sub/array and left corner
# @param clear [Bool] whether to remove current sub surfaces
+ # @param bound [Bool] whether to add subs wrt surface's bounded box
+ # @param realign [Bool] whether to first realign bounded box
# @param bfr [#to_f] safety buffer, to maintain near other edges
#
# @return [Bool] whether addition is successful
# @return [false] if invalid input (see logs)
- def addSubs(s = nil, subs = [], clear = false, bfr = 0.005)
+ def addSubs(s = nil, subs = [], clear = false, bound = false, realign = false, bfr = 0.005)
mth = "OSut::#{__callee__}"
v = OpenStudio.openStudioVersion.split(".").join.to_i
cl1 = OpenStudio::Model::Surface
cl2 = Array
cl3 = Hash
@@ -5142,27 +5291,51 @@
max = 0.950 # maximum ratio value (95%)
no = false
# --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- #
# Exit if mismatched or invalid argument classes.
- return mismatch("surface", s, cl2, mth, DBG, no) unless s.is_a?(cl1)
- return mismatch("subs", subs, cl3, mth, DBG, no) unless subs.is_a?(cl2)
+ sbs = subs.is_a?(cl3) ? [subs] : subs
+ sbs = sbs.respond_to?(:to_a) ? sbs.to_a : []
+ return mismatch("surface", s, cl1, mth, DBG, no) unless s.is_a?(cl1)
+ return mismatch("subs", subs, cl2, mth, DBG, no) if sbs.empty?
return empty("surface points", mth, DBG, no) if poly(s).empty?
- # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- #
- # Clear existing sub surfaces if requested.
- nom = s.nameString
- mdl = s.model
+ subs = sbs
+ nom = s.nameString
+ mdl = s.model
+ # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- #
+ # Purge existing sub surfaces?
unless [true, false].include?(clear)
log(WRN, "#{nom}: Keeping existing sub surfaces (#{mth})")
clear = false
end
s.subSurfaces.map(&:remove) if clear
# --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- #
+ # 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.
+ unless [true, false].include?(bound)
+ log(WRN, "#{nom}: Ignoring bounded box (#{mth})")
+ bound = false
+ end
+
+ # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- #
+ # 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).
+ unless [true, false].include?(realign)
+ log(WRN, "#{nom}: Ignoring realignment (#{mth})")
+ realign = false
+ end
+
+ # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- #
# Ensure minimum safety buffer.
if bfr.respond_to?(:to_f)
bfr = bfr.to_f
return negative("safety buffer", mth, ERR, no) if bfr.round(2) < 0
@@ -5190,19 +5363,25 @@
# --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- #
t = OpenStudio::Transformation.alignFace(s.vertices)
s0 = poly(s, false, false, false, t, :ulc)
s00 = nil
- if facingUp?(s) || facingDown?(s) # TODO: redundant check?
- s00 = getRealignedFace(s0)
- return false unless s00[:set]
+ # Adapt sandbox if user selects to 'bound' and/or 'realign'.
+ if bound
+ box = boundedBox(s0)
- s0 = s00[:set]
+ if realign
+ s00 = getRealignedFace(box, true)
+ return invalid("bound realignment", mth, 0, DBG, false) unless s00[:set]
+ end
+ elsif realign
+ s00 = getRealignedFace(s0, false)
+ return invalid("unbound realignment", mth, 0, DBG, false) unless s00[:set]
end
- max_x = width(s0)
- max_y = height(s0)
+ max_x = s00 ? width( s00[:set]) : width(s0)
+ max_y = s00 ? height(s00[:set]) : height(s0)
mid_x = max_x / 2
mid_y = max_y / 2
# --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- #
# Assign default values to certain sub keys (if missing), +more validation.
@@ -5217,11 +5396,11 @@
sub[:id ] = "" unless sub.key?(:id )
sub[:type ] = type unless sub.key?(:type )
sub[:type ] = trim(sub[:type])
sub[:id ] = trim(sub[:id])
sub[:type ] = type if sub[:type].empty?
- sub[:id ] = "OSut|#{nom}|#{index}" if sub[:id ].empty?
+ sub[:id ] = "OSut:#{nom}:#{index}" if sub[:id ].empty?
sub[:count ] = 1 unless sub[:count ].respond_to?(:to_i)
sub[:multiplier] = 1 unless sub[:multiplier].respond_to?(:to_i)
sub[:count ] = sub[:count ].to_i
sub[:multiplier] = sub[:multiplier].to_i
sub[:count ] = 1 if sub[:count ] < 1
@@ -5269,12 +5448,14 @@
next if key == :type
next if key == :id
next if key == :frame
next if key == :assembly
- ok = value.respond_to?(:to_f)
- return mismatch(key, value, Float, mth, DBG, no) unless ok
+ unless value.respond_to?(:to_f)
+ return mismatch(key, value, Float, mth, DBG, no)
+ end
+
next if key == :centreline
negative(key, mth, WRN) if value < 0
value = 0.0 if value.abs < TOL
end
@@ -5327,31 +5508,31 @@
end
# Log/reset "height" if beyond min/max.
if sub.key?(:height)
unless sub[:height].between?(glass - TOL2, max_height + TOL2)
- sub[:height] = glass if sub[:height] < glass
- sub[:height] = max_height if sub[:height] > max_height
- log(WRN, "Reset '#{id}' height to #{sub[:height]} m (#{mth})")
+ log(WRN, "Reset '#{id}' height #{sub[:height].round(3)}m (#{mth})")
+ sub[:height] = sub[:height].clamp(glass, max_height)
+ log(WRN, "Height '#{id}' reset to #{sub[:height].round(3)}m (#{mth})")
end
end
# Log/reset "head" height if beyond min/max.
if sub.key?(:head)
unless sub[:head].between?(min_head - TOL2, max_head + TOL2)
- sub[:head] = max_head if sub[:head] > max_head
- sub[:head] = min_head if sub[:head] < min_head
- log(WRN, "Reset '#{id}' head height to #{sub[:head]} m (#{mth})")
+ log(WRN, "Reset '#{id}' head #{sub[:head].round(3)}m (#{mth})")
+ sub[:head] = sub[:head].clamp(min_head, max_head)
+ log(WRN, "Head '#{id}' reset to #{sub[:head].round(3)}m (#{mth})")
end
end
# Log/reset "sill" height if beyond min/max.
if sub.key?(:sill)
unless sub[:sill].between?(min_sill - TOL2, max_sill + TOL2)
- sub[:sill] = max_sill if sub[:sill] > max_sill
- sub[:sill] = min_sill if sub[:sill] < min_sill
- log(WRN, "Reset '#{id}' sill height to #{sub[:sill]} m (#{mth})")
+ log(WRN, "Reset '#{id}' sill #{sub[:sill].round(3)}m (#{mth})")
+ sub[:sill] = sub[:sill].clamp(min_sill, max_sill)
+ log(WRN, "Sill '#{id}' reset to #{sub[:sill].round(3)}m (#{mth})")
end
end
# At this point, "head", "sill" and/or "height" have been tentatively
# validated (and/or have been corrected) independently from one another.
@@ -5366,22 +5547,24 @@
sub[:height ] = 0 if sub.key?(:height)
sub[:width ] = 0 if sub.key?(:width)
log(ERR, "Skip: invalid '#{id}' head/sill combo (#{mth})")
next
else
+ log(WRN, "(Re)set '#{id}' sill #{sub[:sill].round(3)}m (#{mth})")
sub[:sill] = sill
- log(WRN, "(Re)set '#{id}' sill height to #{sub[:sill]} m (#{mth})")
+ log(WRN, "Sill '#{id}' (re)set to #{sub[:sill].round(3)}m (#{mth})")
end
end
# Attempt to reconcile "head", "sill" and/or "height". If successful,
# all 3x parameters are set (if missing), or reset if invalid.
if sub.key?(:head) && sub.key?(:sill)
height = sub[:head] - sub[:sill]
if sub.key?(:height) && (sub[:height] - height).abs > TOL2
- log(WRN, "(Re)set '#{id}' height to #{height} m (#{mth})")
+ log(WRN, "(Re)set '#{id}' height #{sub[:height].round(3)}m (#{mth})")
+ log(WRN, "Height '#{id}' (re)set to #{height.round(3)}m (#{mth})")
end
sub[:height] = height
elsif sub.key?(:head) # no "sill"
if sub.key?(:height)
@@ -5398,13 +5581,14 @@
sub[:height ] = 0 if sub.key?(:height)
sub[:width ] = 0 if sub.key?(:width)
log(ERR, "Skip: invalid '#{id}' head/height combo (#{mth})")
next
else
+ log(WRN, "(Re)set '#{id}' height #{sub[:height].round(3)}m (#{mth})")
sub[:sill ] = sill
sub[:height] = height
- log(WRN, "(Re)set '#{id}' height to #{sub[:height]} m (#{mth})")
+ log(WRN, "Height '#{id}' re(set) #{sub[:height].round(3)}m (#{mth})")
end
else
sub[:sill] = sill
end
else
@@ -5426,13 +5610,14 @@
sub[:height ] = 0 if sub.key?(:height)
sub[:width ] = 0 if sub.key?(:width)
log(ERR, "Skip: invalid '#{id}' sill/height combo (#{mth})")
next
else
+ log(WRN, "(Re)set '#{id}' height #{sub[:height].round(3)}m (#{mth})")
sub[:head ] = head
sub[:height] = height
- log(WRN, "(Re)set '#{id}' height to #{sub[:height]} m (#{mth})")
+ log(WRN, "Height '#{id}' reset to #{sub[:height].round(3)}m (#{mth})")
end
else
sub[:head] = head
end
else
@@ -5457,43 +5642,45 @@
end
# Log/reset "width" if beyond min/max.
if sub.key?(:width)
unless sub[:width].between?(glass - TOL2, max_width + TOL2)
- sub[:width] = glass if sub[:width] < glass
- sub[:width] = max_width if sub[:width] > max_width
- log(WRN, "Reset '#{id}' width to #{sub[:width]} m (#{mth})")
+ log(WRN, "Reset '#{id}' width #{sub[:width].round(3)}m (#{mth})")
+ sub[:width] = sub[:width].clamp(glass, max_width)
+ log(WRN, "Width '#{id}' reset to #{sub[:width].round(3)}m (#{mth})")
end
end
# Log/reset "count" if < 1 (or not an Integer)
if sub[:count].respond_to?(:to_i)
sub[:count] = sub[:count].to_i
if sub[:count] < 1
sub[:count] = 1
- log(WRN, "Reset '#{id}' count to #{sub[:count]} (#{mth})")
+ log(WRN, "Reset '#{id}' count to min 1 (#{mth})")
end
else
sub[:count] = 1
end
sub[:count] = 1 unless sub.key?(:count)
# Log/reset if left-sided buffer under min jamb position.
if sub.key?(:l_buffer)
if sub[:l_buffer] < min_ljamb - TOL
+ log(WRN, "Reset '#{id}' left buffer #{sub[:l_buffer].round(3)}m (#{mth})")
sub[:l_buffer] = min_ljamb
- log(WRN, "Reset '#{id}' left buffer to #{sub[:l_buffer]} m (#{mth})")
+ log(WRN, "Left buffer '#{id}' reset to #{sub[:l_buffer].round(3)}m (#{mth})")
end
end
# Log/reset if right-sided buffer beyond max jamb position.
if sub.key?(:r_buffer)
if sub[:r_buffer] > max_rjamb - TOL
+ log(WRN, "Reset '#{id}' right buffer #{sub[:r_buffer].round(3)}m (#{mth})")
sub[:r_buffer] = min_rjamb
- log(WRN, "Reset '#{id}' right buffer to #{sub[:r_buffer]} m (#{mth})")
+ log(WRN, "Right buffer '#{id}' reset to #{sub[:r_buffer].round(3)}m (#{mth})")
end
end
centre = mid_x
centre += sub[:centreline] if sub.key?(:centreline)
@@ -5509,19 +5696,19 @@
sub[:ratio ] = 0
sub[:count ] = 0
sub[:multiplier] = 0
sub[:height ] = 0 if sub.key?(:height)
sub[:width ] = 0 if sub.key?(:width)
- log(ERR, "Skip: '#{id}' ratio ~0 (#{mth})")
+ log(ERR, "Skip: ratio ~0 (#{mth})")
next
end
# Log/reset if "ratio" beyond min/max?
unless sub[:ratio].between?(min, max)
- sub[:ratio] = min if sub[:ratio] < min
- sub[:ratio] = max if sub[:ratio] > max
- log(WRN, "Reset ratio (min/max) to #{sub[:ratio]} (#{mth})")
+ log(WRN, "Reset ratio #{sub[:ratio].round(3)} (#{mth})")
+ sub[:ratio] = sub[:ratio].clamp(min, max)
+ log(WRN, "Ratio reset to #{sub[:ratio].round(3)} (#{mth})")
end
# Log/reset "count" unless 1.
unless sub[:count] == 1
sub[:count] = 1
@@ -5534,19 +5721,19 @@
x0 = centre - w/2
xf = centre + w/2
if sub.key?(:l_buffer)
if sub.key?(:centreline)
- log(WRN, "Skip #{id} left buffer (vs centreline) (#{mth})")
+ log(WRN, "Skip '#{id}' left buffer (vs centreline) (#{mth})")
else
x0 = sub[:l_buffer] - frame
xf = x0 + w
centre = x0 + w/2
end
elsif sub.key?(:r_buffer)
if sub.key?(:centreline)
- log(WRN, "Skip #{id} right buffer (vs centreline) (#{mth})")
+ log(WRN, "Skip '#{id}' right buffer (vs centreline) (#{mth})")
else
xf = max_x - sub[:r_buffer] + frame
x0 = xf - w
centre = x0 + w/2
end
@@ -5557,17 +5744,18 @@
sub[:ratio ] = 0 if sub.key?(:ratio)
sub[:count ] = 0
sub[:multiplier] = 0
sub[:height ] = 0 if sub.key?(:height)
sub[:width ] = 0 if sub.key?(:width)
- log(ERR, "Skip: invalid (ratio) width/centreline (#{mth})")
+ log(ERR, "Skip '#{id}': invalid (ratio) width/centreline (#{mth})")
next
end
if sub.key?(:width) && (sub[:width] - width).abs > TOL
+ log(WRN, "Reset '#{id}' width (ratio) #{sub[:width].round(2)}m (#{mth})")
sub[:width] = width
- log(WRN, "Reset width (ratio) to #{sub[:width]} (#{mth})")
+ log(WRN, "Width (ratio) '#{id}' reset to #{sub[:width].round(2)}m (#{mth})")
end
sub[:width] = width unless sub.key?(:width)
else
unless sub.key?(:width)
@@ -5581,16 +5769,17 @@
end
width = sub[:width] + frames
gap = (max_x - n * width) / (n + 1)
gap = sub[:offset] - width if sub.key?(:offset)
- gap = 0 if gap < bfr
+ gap = 0 if gap < buffer
offset = gap + width
if sub.key?(:offset) && (offset - sub[:offset]).abs > TOL
+ log(WRN, "Reset '#{id}' sub offset #{sub[:offset].round(2)}m (#{mth})")
sub[:offset] = offset
- log(WRN, "Reset sub offset to #{sub[:offset]} m (#{mth})")
+ log(WRN, "Sub offset (#{id}) reset to #{sub[:offset].round(2)}m (#{mth})")
end
sub[:offset] = offset unless sub.key?(:offset)
# Overall width (including frames) of bounding box around array.
@@ -5598,28 +5787,28 @@
x0 = centre - w/2
xf = centre + w/2
if sub.key?(:l_buffer)
if sub.key?(:centreline)
- log(WRN, "Skip #{id} left buffer (vs centreline) (#{mth})")
+ log(WRN, "Skip '#{id}' left buffer (vs centreline) (#{mth})")
else
x0 = sub[:l_buffer] - frame
xf = x0 + w
centre = x0 + w/2
end
elsif sub.key?(:r_buffer)
if sub.key?(:centreline)
- log(WRN, "Skip #{id} right buffer (vs centreline) (#{mth})")
+ log(WRN, "Skip '#{id}' right buffer (vs centreline) (#{mth})")
else
xf = max_x - sub[:r_buffer] + frame
x0 = xf - w
centre = x0 + w/2
end
end
# Too wide?
- if x0 < bfr - TOL2 || xf > max_x - bfr - TOL2
+ if x0 < buffer - TOL2 || xf > max_x - buffer - TOL2
sub[:ratio ] = 0 if sub.key?(:ratio)
sub[:count ] = 0
sub[:multiplier] = 0
sub[:height ] = 0 if sub.key?(:height)
sub[:width ] = 0 if sub.key?(:width)
@@ -5631,52 +5820,58 @@
# Initialize left-side X-axis coordinate of only/first sub.
pos = x0 + frame
# Generate sub(s).
sub[:count].times do |i|
- name = "#{id}|#{i}"
+ name = "#{id}:#{i}"
fr = 0
fr = sub[:frame].frameWidth if sub[:frame]
-
vec = OpenStudio::Point3dVector.new
vec << OpenStudio::Point3d.new(pos, sub[:head], 0)
vec << OpenStudio::Point3d.new(pos, sub[:sill], 0)
vec << OpenStudio::Point3d.new(pos + sub[:width], sub[:sill], 0)
vec << OpenStudio::Point3d.new(pos + sub[:width], sub[:head], 0)
vec = s00 ? t * (s00[:r] * (s00[:t] * vec)) : t * vec
# Log/skip if conflict between individual sub and base surface.
vc = vec
vc = offset(vc, fr, 300) if fr > 0
- ok = fits?(vc, s)
- log(ERR, "Skip '#{name}': won't fit in '#{nom}' (#{mth})") unless ok
- break unless ok
+ unless fits?(vc, s)
+ log(ERR, "Skip '#{name}': won't fit in '#{nom}' (#{mth})")
+ break
+ end
# Log/skip if conflicts with existing subs (even if same array).
+ conflict = false
+
s.subSurfaces.each do |sb|
nome = sb.nameString
fd = sb.windowPropertyFrameAndDivider
- fr = 0 if fd.empty?
- fr = fd.get.frameWidth unless fd.empty?
+ fr = fd.empty? ? 0 : fd.get.frameWidth
vk = sb.vertices
vk = offset(vk, fr, 300) if fr > 0
- oops = overlaps?(vc, vk)
- log(ERR, "Skip '#{name}': overlaps '#{nome}' (#{mth})") if oops
- ok = false if oops
- break if oops
+
+ if overlaps?(vc, vk)
+ log(ERR, "Skip '#{name}': overlaps '#{nome}' (#{mth})")
+ conflict = true
+ break
+ end
end
- break unless ok
+ break if conflict
sb = OpenStudio::Model::SubSurface.new(vec, mdl)
sb.setName(name)
sb.setSubSurfaceType(sub[:type])
- sb.setConstruction(sub[:assembly]) if sub[:assembly]
- ok = sb.allowWindowPropertyFrameAndDivider
- sb.setWindowPropertyFrameAndDivider(sub[:frame]) if sub[:frame] && ok
- sb.setMultiplier(sub[:multiplier]) if sub[:multiplier] > 1
+ sb.setConstruction(sub[:assembly]) if sub[:assembly]
+ sb.setMultiplier(sub[:multiplier]) if sub[:multiplier] > 1
+
+ if sub[:frame] && sb.allowWindowPropertyFrameAndDivider
+ sb.setWindowPropertyFrameAndDivider(sub[:frame])
+ end
+
sb.setSurface(s)
# Reset "pos" if array.
pos += sub[:offset] if sub.key?(:offset)
end
@@ -5684,45 +5879,29 @@
true
end
##
- # Validates whether surface is considered a sloped roof (outdoor-facing,
- # 10% < tilt < 90%).
- #
- # @param s [OpenStudio::Model::Surface] a model surface
- #
- # @return [Bool] whether surface is a sloped roof
- # @return [false] if invalid input (see logs)
- def slopedRoof?(s = nil)
- mth = "OSut::#{__callee__}"
- cl = OpenStudio::Model::Surface
- return mismatch("surface", s, cl, mth, DBG, false) unless s.is_a?(cl)
-
- return false if facingUp?(s)
- return false if facingDown?(s)
-
- true
- end
-
- ##
# Returns the "gross roof 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.
+ # 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 'roof?'.
def grossRoofArea(spaces = [])
mth = "OSut::#{__callee__}"
up = OpenStudio::Point3d.new(0,0,1) - OpenStudio::Point3d.new(0,0,0)
rm2 = 0
rfs = {}
spaces = spaces.is_a?(OpenStudio::Model::Space) ? [spaces] : spaces
spaces = spaces.respond_to?(:to_a) ? spaces.to_a : []
spaces = spaces.select { |space| space.is_a?(OpenStudio::Model::Space) }
spaces = spaces.select { |space| space.partofTotalFloorArea }
+ spaces = spaces.reject { |space| unconditioned?(space) }
return invalid("spaces", mth, 1, DBG, 0) if spaces.empty?
# The method is very similar to OpenStudio-Standards' :
# find_exposed_conditioned_roof_surfaces(model)
#
@@ -5730,21 +5909,22 @@
# be81bd88dc55a44d8cce3ee6daf29c768032df6a/lib/openstudio-standards/
# standards/Standards.Surface.rb#L99
#
# ... yet differs with regards to attics with overhangs/soffits.
- # Start with roof surfaces of occupied spaces.
+ # Start with roof surfaces of occupied, conditioned spaces.
spaces.each do |space|
facets(space, "Outdoors", "RoofCeiling").each do |roof|
next if rfs.key?(roof)
+ next unless roof?(roof)
rfs[roof] = {m2: roof.grossArea, m: space.multiplier}
end
end
# Roof surfaces of unoccupied, conditioned spaces above (e.g. plenums)?
- # TO DO: recursive call for stacked spaces as atria (via AirBoundaries).
+ # @todo: recursive call for stacked spaces as atria (via AirBoundaries).
spaces.each do |space|
facets(space, "Surface", "RoofCeiling").each do |ceiling|
floor = ceiling.adjacentSurface
next if floor.empty?
@@ -5755,18 +5935,19 @@
next if other.partofTotalFloorArea
next if unconditioned?(other)
facets(other, "Outdoors", "RoofCeiling").each do |roof|
next if rfs.key?(roof)
+ next unless roof?(roof)
rfs[roof] = {m2: roof.grossArea, m: other.multiplier}
end
end
end
# Roof surfaces of unoccupied, unconditioned spaces above (e.g. attics)?
- # TO DO: recursive call for stacked spaces as atria (via AirBoundaries).
+ # @todo: recursive call for stacked spaces as atria (via AirBoundaries).
spaces.each do |space|
# When taking overlaps into account, the target space may not share the
# same local transformation as the space(s) above.
t0 = transforms(space)
next unless t0[:t]
@@ -5790,18 +5971,20 @@
next unless ti[:t]
ti = ti[:t]
facets(other, "Outdoors", "RoofCeiling").each do |roof|
+ next unless roof?(roof)
+
rvi = ti * roof.vertices
cst = cast(cv0, rvi, up)
next if cst.empty?
# The overlap calculation fails for roof and ceiling surfaces with
# previously-added leader lines.
#
- # TODO: revise approach for attics ONCE skylight wells have been added.
+ # @todo: revise approach for attics ONCE skylight wells have been added.
olap = nil
olap = overlap(cst, rvi, false)
next if olap.empty?
m2 = OpenStudio.getArea(olap)
@@ -5821,26 +6004,27 @@
rm2
end
##
- # Identifies horizontal ridges between 2x sloped roof surfaces (same space).
- # If successful, the returned Array holds 'ridge' Hashes. Each Hash holds: an
+ # Identifies horizontal ridges along 2x sloped (roof?) surfaces (same space).
+ # The concept of 'sloped' is harmonized with OpenStudio's "alignZPrime". If
+ # successful, the returned Array holds 'ridge' Hashes. Each Hash holds: an
# :edge (OpenStudio::Point3dVector), the edge :length (Numeric), and :roofs
- # (Array of 2x linked roof surfaces). Each roof surface may be linked to more
- # than one horizontal ridge.
+ # (Array of 2x linked surfaces). Each surface may be linked to more than one
+ # horizontal ridge.
#
- # @param roofs [Array<OpenStudio::Model::Surface>] target roof surfaces
+ # @param roofs [Array<OpenStudio::Model::Surface>] target surfaces
#
# @return [Array] horizontal ridges (see logs if empty)
def getHorizontalRidges(roofs = [])
mth = "OSut::#{__callee__}"
ridges = []
return ridges unless roofs.is_a?(Array)
roofs = roofs.select { |s| s.is_a?(OpenStudio::Model::Surface) }
- roofs = roofs.select { |s| slopedRoof?(s) }
+ roofs = roofs.select { |s| sloped?(s) }
roofs.each do |roof|
maxZ = roof.vertices.max_by(&:z).z
next if roof.space.empty?
@@ -5871,11 +6055,11 @@
next if ruf == roof
next if ruf.space.empty?
next unless ruf.space.get == space
getSegments(ruf).each do |edg|
- break if match
+ break if match
next unless same?(edge, edg) || same?(edge, edg.reverse)
ridge[:roofs] << ruf
ridges << ridge
match = true
@@ -5886,53 +6070,183 @@
ridges
end
##
+ # Preselects ideal spaces to toplight, based on 'addSkylights' options and key
+ # building model geometry attributes. Can be called from within 'addSkylights'
+ # by setting :ration (opts key:value argument) to 'true' ('false' by default).
+ # Alternatively, the method can be called prior to 'addSkylights'. The set of
+ # filters stems from previous rounds of 'addSkylights' stress testing. It is
+ # intended as an option 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 reset to 'true'.
+ #
+ # @param spaces [Array<OpenStudio::Model::Space>] candidate(s) to toplight
+ # @param [Hash] opts requested skylight attributes (same as 'addSkylights')
+ # @option opts [#to_f] :size (1.22m) template skylight width/depth (min 0.4m)
+ #
+ # @return [Array<OpenStudio::Model::Space>] candidates (see logs if empty)
+ def toToplit(spaces = [], opts = {})
+ mth = "OSut::#{__callee__}"
+ gap4 = 0.4 # minimum skylight 16" width/depth (excluding frame width)
+ w = 1.22 # default 48" x 48" skylight base
+ w2 = w * w
+
+ # Validate skylight size, if provided.
+ if opts.key?(:size)
+ if opts[:size].respond_to?(:to_f)
+ w = opts[:size].to_f
+ w2 = w * w
+ return invalid(size, mth, 0, ERR, []) if w.round(2) < gap4
+ else
+ return mismatch("size", opts[:size], Numeric, mth, DBG, [])
+ end
+ end
+
+ # Accept single 'OpenStudio::Model::Space' (vs an array of spaces). Filter.
+ #
+ # 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.
+ if spaces.respond_to?(:spaceType) || spaces.respond_to?(:to_a)
+ spaces = spaces.respond_to?(:to_a) ? spaces.to_a : [spaces]
+ spaces = spaces.select { |sp| sp.respond_to?(:spaceType) }
+ spaces = spaces.select { |sp| sp.partofTotalFloorArea }
+ spaces = spaces.reject { |sp| unconditioned?(sp) }
+ spaces = spaces.reject { |sp| vestibule?(sp) }
+ spaces = spaces.reject { |sp| getRoofs(sp).empty? }
+ spaces = spaces.reject { |sp| sp.floorArea < 4 * w2 }
+ spaces = spaces.sort_by(&:floorArea).reverse
+ return empty("spaces", mth, WRN, 0) if spaces.empty?
+ else
+ return mismatch("spaces", spaces, Array, mth, DBG, 0)
+ end
+
+ # 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 = spaces.sum(&:floorArea)
+ afm2 = fm2 / spaces.size
+
+ unfen = spaces.reject { |sp| daylit?(sp) }.sort_by(&:floorArea).reverse
+
+ # Target larger unfenestrated spaces, if sufficient in area.
+ if unfen.empty?
+ opts[:sidelit] = true
+ else
+ if spaces.size > unfen.size
+ ufm2 = unfen.sum(&:floorArea)
+ u0fm2 = unfen.first.floorArea
+
+ if ufm2 > 0.33 * fm2 && u0fm2 > 3 * afm2
+ unfen = unfen.reject { |sp| sp.floorArea > 0.25 * afm2 }
+ spaces = spaces.reject { |sp| unfen.include?(sp) }
+ else
+ opts[:sidelit] = true
+ end
+ end
+ end
+
+ espaces = {}
+ rooms = []
+ toits = []
+
+ # Gather roof surfaces - possibly those of attics or plenums above.
+ spaces.each do |sp|
+ getRoofs(sp).each do |rf|
+ espaces[sp] = {roofs: []} unless espaces.key?(sp)
+ espaces[sp][:roofs] << rf unless espaces[sp][:roofs].include?(rf)
+ end
+ end
+
+ # Priortize larger spaces.
+ espaces = espaces.sort_by { |espace, _| espace.floorArea }.reverse
+
+ # Prioritize larger roof surfaces.
+ espaces.each do |_, datum|
+ datum[:roofs] = datum[:roofs].sort_by(&:grossArea).reverse
+ end
+
+ # Single out largest roof in largest space, key when dealing with shared
+ # attics or plenum roofs.
+ espaces.each do |espace, datum|
+ rfs = datum[:roofs].reject { |ruf| toits.include?(ruf) }
+ next if rfs.empty?
+
+ toits << rfs.sort { |ruf| ruf.grossArea }.reverse.first
+ rooms << espace
+ end
+
+ log(INF, "No ideal toplit candidates (#{mth})") if rooms.empty?
+
+ rooms
+ end
+
+ ##
# Adds skylights to toplight selected OpenStudio (occupied, conditioned)
- # spaces, based on requested skylight-to-roof (SRR%) options (max 10%). If the
- # user selects 0% (0.0) as the :srr while keeping :clear as true, the method
- # simply purges all pre-existing roof subsurfaces (whether glazed or not) of
- # selected spaces, and exits while returning 0 (without logging an error or
- # warning). Pre-toplit spaces are otherwise ignored. Boolean options :attic,
- # :plenum, :sloped and :sidelit, further restrict candidate roof surfaces. If
- # applicable, options :attic and :plenum add skylight wells. Option :patterns
- # restricts preset skylight allocation strategies in order of preference; if
- # left empty, all preset patterns are considered, also in order of preference
- # (see examples).
+ # spaces, based on requested skylight area, or a skylight-to-roof ratio (SRR%).
+ # If the user selects 0m2 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).
#
# @param spaces [Array<OpenStudio::Model::Space>] space(s) to toplight
# @param [Hash] opts requested skylight attributes
- # @option opts [#to_f] :srr skylight-to-roof ratio (0.00, 0.10]
+ # @option opts [#to_f] :area overall skylight area
+ # @option opts [#to_f] :srr skylight-to-roof ratio (0.00, 0.90]
# @option opts [#to_f] :size (1.22) template skylight width/depth (min 0.4m)
# @option opts [#frameWidth] :frame (nil) OpenStudio Frame & Divider (optional)
# @option opts [Bool] :clear (true) whether to first purge existing skylights
+ # @option opts [Bool] :ration (true) finer selection of candidates to toplight
# @option opts [Bool] :sidelit (true) whether to consider sidelit spaces
# @option opts [Bool] :sloped (true) whether to consider sloped roof surfaces
# @option opts [Bool] :plenum (true) whether to consider plenum wells
# @option opts [Bool] :attic (true) whether to consider attic wells
# @option opts [Array<#to_s>] :patterns requested skylight allocation (3x)
- # @example (a) consider 2D array of individual skylights, e.g. n(1.2m x 1.2m)
+ # @example (a) consider 2D array of individual skylights, e.g. n(1.22m x 1.22m)
# opts[:patterns] = ["array"]
# @example (b) consider 'a', then array of 1x(size) x n(size) skylight strips
# opts[:patterns] = ["array", "strips"]
#
- # @return [Float] returns gross roof area if successful (see logs if 0 m2)
+ # @return [Float] returns gross roof area if successful (see logs if 0m2)
def addSkyLights(spaces = [], opts = {})
mth = "OSut::#{__callee__}"
clear = true
- srr = 0.0
+ srr = nil
+ area = nil
frame = nil # FrameAndDivider object
f = 0.0 # FrameAndDivider frame width
- gap = 0.1 # min 2" around well (2x), as well as max 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
-
# --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- #
# Excerpts of ASHRAE 90.1 2022 definitions:
#
# "ROOF":
#
@@ -5992,11 +6306,11 @@
# 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 of
- # surfaces ontoothers, as well as overlap calculations. If successful, the
+ # 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):
#
@@ -6012,11 +6326,11 @@
# (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
+ # 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.new(0,0,0)
@@ -6035,24 +6349,13 @@
return mismatch("spaces", spaces, Array, mth, DBG, 0)
end
mdl = spaces.first.model
- # Exit if mismatched or invalid argument classes/keys.
+ # Exit if mismatched or invalid options.
return mismatch("opts", opts, Hash, mth, DBG, 0) unless opts.is_a?(Hash)
- return hashkey( "srr", opts, :srr, mth, ERR, 0) unless opts.key?(:srr)
- # Validate requested skylight-to-roof ratio.
- if opts[:srr].respond_to?(:to_f)
- srr = opts[:srr].to_f
- log(WRN, "Resetting srr to 0% (#{mth})") if srr < 0
- log(WRN, "Resetting srr to 10% (#{mth})") if srr > 0.10
- srr = srr.clamp(0.00, 0.10)
- else
- return mismatch("srr", opts[:srr], Numeric, mth, DBG, 0)
- end
-
# Validate Frame & Divider object, if provided.
if opts.key?(:frame)
frame = opts[:frame]
if frame.respond_to?(:frameWidth)
@@ -6083,72 +6386,155 @@
w0 = w + f2
w02 = w0 * w0
wl = w0 + gap
wl2 = wl * wl
+ # Validate requested skylight-to-roof ratio (or overall area).
+ if opts.key?(:area)
+ if opts[:area].respond_to?(:to_f)
+ area = opts[:area].to_f
+ log(WRN, "Area reset to 0.0m2 (#{mth})") if area < 0
+ else
+ return mismatch("area", opts[:area], Numeric, mth, DBG, 0)
+ end
+ elsif opts.key?(:srr)
+ if opts[:srr].respond_to?(:to_f)
+ srr = opts[:srr].to_f
+ log(WRN, "SRR (#{srr.round(2)}) reset to 0% (#{mth})") if srr < 0
+ log(WRN, "SRR (#{srr.round(2)}) reset to 90% (#{mth})") if srr > 0.90
+ srr = srr.clamp(0.00, 0.10)
+ else
+ return mismatch("srr", opts[:srr], Numeric, mth, DBG, 0)
+ end
+ else
+ return hashkey("area", opts, :area, mth, ERR, 0)
+ end
+
# Validate purge request, if provided.
if opts.key?(:clear)
clear = opts[:clear]
unless [true, false].include?(clear)
log(WRN, "Purging existing skylights by default (#{mth})")
clear = true
end
end
+ # Purge if requested.
getRoofs(spaces).each { |s| s.subSurfaces.map(&:remove) } if clear
# Safely exit, e.g. if strictly called to purge existing roof subsurfaces.
- return 0 if srr < TOL
+ return 0 if area && area.round(2) == 0
+ return 0 if srr && srr.round(2) == 0
+ m2 = 0 # total existing skylight rough opening area
+ rm2 = grossRoofArea(spaces) # excludes e.g. overhangs
+
+ # Tally existing skylight rough opening areas.
+ spaces.each do |space|
+ m = space.multiplier
+
+ facets(space, "Outdoors", "RoofCeiling").each do |roof|
+ roof.subSurfaces.each do |sub|
+ next unless fenestration?(sub)
+
+ id = sub.nameString
+ xm2 = sub.grossArea
+
+ if sub.allowWindowPropertyFrameAndDivider
+ unless sub.windowPropertyFrameAndDivider.empty?
+ fw = sub.windowPropertyFrameAndDivider.get.frameWidth
+ vec = offset(sub.vertices, fw, 300)
+ aire = OpenStudio.getArea(vec)
+
+ if aire.empty?
+ log(ERR, "Skipping '#{id}': invalid Frame&Divider (#{mth})")
+ else
+ xm2 = aire.get
+ end
+ end
+ end
+
+ m2 += xm2 * sub.multiplier * m
+ end
+ end
+ end
+
+ # Required skylight area to add.
+ sm2 = area ? area : rm2 * srr - m2
+
+ # Warn/skip if existing skylights exceed or ~roughly match targets.
+ if sm2.round(2) < w02.round(2)
+ if m2 > 0
+ log(INF, "Skipping: existing skylight area > request (#{mth})")
+ return rm2
+ else
+ log(INF, "Requested skylight area < min size (#{mth})")
+ end
+ elsif 0.9 * rm2.round(2) < sm2.round(2)
+ log(INF, "Skipping: requested skylight area > 90% of GRA (#{mth})")
+ return rm2
+ end
+
+ opts[:ration] = true unless opts.key?(:ration)
+
+ # By default, seek ideal candidate spaces/roofs. Bail out if unsuccessful.
+ unless opts[:ration] == false
+ spaces = toToplit(spaces, opts)
+ return rm2 if spaces.empty?
+ end
+
# --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- #
# 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/right + top/bottom (as illustrated below), once a roof
- # (or cast 3D overlap) is 'aligned' in 2D (possibly also 'realigned').
+ # 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 desired %SRR. If :patterns are left unaltered, the method will
- # select patterns that maximize the likelihood of attaining the requested
- # %SRR, to the detriment of spatial distribution of daylighting.
+ # 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.2m x 1.2m (4' x 4'), which be
- # overridden by the user, e.g. 2.4m x 2.4m (8' x 8').
+ # 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.2m x 1.2m), as illustrated
- # | | - SRR ~19% (2.4m x 2.4m)
+ # | |_| |_| |_| | - 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.2m x ?1.2m), as illustrated
- # | | | | | | | | - SRR ~19% (2.4m x ?1.2m)
+ # | | | | | | | | - 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.2m x ?1.2m)
- # | | ............ | | - SRR ~22% (2.4m x ?1.2m), as illustrated
+ # | ______________ | - SRR ~11% (1.22m x ?1.22m)
+ # | | ............ | | - SRR ~22% (2.44m x ?1.22m), as illustrated
# | |______________| | - +suitable for elongated bounded boxes
# | | - 1x well
# |____________________|
#
- # TO-DO: Support strips/strip patterns along ridge of paired roof surfaces.
+ # @todo: Support strips/strip patterns along ridge of paired roof surfaces.
layouts = ["array", "strips", "strip"]
patterns = []
# Validate skylight placement patterns, if provided.
if opts.key?(:patterns)
@@ -6172,31 +6558,30 @@
# 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)
- # - not under plenums, nor attics (avoids wells)
+ # - neither under plenums, nor attics (avoids wells)
#
# This ideal (albeit stringent) set of conditions is "combo a".
#
- # If required %SRR has not yet been achieved, the method decrementally drops
- # selection criteria and starts over, e.g.:
+ # 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. Priority is always given to larger roof areas. If
- # unsuccessful in meeting the required %SRR target, a single criterion is
- # then dropped (e.g. b, then c, etc.), and the allocation process is
- # relaunched. An error message is logged if the %SRR isn't ultimately met.
+ # 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 restrict candidate roof surfaces:
+ # 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)
+ # 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.
[:sidelit, :sloped, :plenum, :attic].each do |opt|
next unless opts.key?(opt)
@@ -6211,12 +6596,12 @@
end
filters.reject! { |f| f.empty? }
filters.uniq!
- # Remaining filters may be further reduced (after space/roof processing),
- # depending on geometry, e.g.:
+ # 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
@@ -6226,198 +6611,181 @@
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)
- # Select candidate 'rooms' to toplit - excludes plenums/attics.
+ # Candidate 'rooms' to toplit - excludes plenums/attics.
spaces.each do |space|
- next if unconditioned?(space) # e.g. attic
- next unless space.partofTotalFloorArea # occupied (not plenum)
+ id = space.nameString
- # Already toplit?
if daylit?(space, false, true, false)
log(WRN, "#{id} is already toplit, skipping (#{mth})")
next
end
# 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 local transformation.
- h = 0
+ # unoccupied space(s) above. Fetching site transformation.
t0 = transforms(space)
next unless t0[:t]
- toitures = facets(space, "Outdoors", "RoofCeiling")
- plafonds = facets(space, "Surface", "RoofCeiling")
+ # Calculate space height.
+ hMIN = 10000
+ hMAX = 0
+ surfs = facets(space)
- toitures.each { |surf| h = [h, surf.vertices.max_by(&:z).z].max }
- plafonds.each { |surf| h = [h, surf.vertices.max_by(&:z).z].max }
+ surfs.each { |surf| hMAX = [hMAX, surf.vertices.max_by(&:z).z].max }
+ surfs.each { |surf| hMIN = [hMIN, surf.vertices.min_by(&:z).z].min }
+ h = hMAX - hMIN
+
+ unless h > 0
+ log(ERR, "#{id} height? #{hMIN.round(2)} vs #{hMAX.round(2)} (#{mth})")
+ next
+ end
+
rooms[space] = {}
- rooms[space][:t ] = t0[:t]
+ rooms[space][:t0 ] = t0[:t]
rooms[space][:m ] = space.multiplier
rooms[space][:h ] = h
- rooms[space][:roofs ] = toitures
+ rooms[space][:roofs ] = facets(space, "Outdoors", "RoofCeiling")
rooms[space][:sidelit] = daylit?(space, true, false, false)
# Fetch and process room-specific outdoor-facing roof surfaces, the most
- # basic 'set' to track:
- # - no skylight wells
+ # basic 'set' to track, e.g.:
+ # - no skylight wells (i.e. no leader lines)
# - 1x skylight array per roof surface
- # - no need to preprocess space transformation
+ # - no need to consider site transformation
rooms[space][:roofs].each do |roof|
+ next unless roof?(roof)
+
box = boundedBox(roof)
next if box.empty?
bm2 = OpenStudio.getArea(box)
next if bm2.empty?
bm2 = bm2.get
next if bm2.round(2) < w02.round(2)
- # Track if bounded box is significantly smaller than roof.
+ width = alignedWidth(box, true)
+ depth = alignedHeight(box, true)
+ next if width < wl * 3
+ next if depth < wl
+
+ # A set is 'tight' if the area of its bounded box is significantly
+ # smaller than that of its roof. A set is 'thin' if the depth of its
+ # bounded box is (too) narrow. If either is true, some geometry rules
+ # may be relaxed to maximize allocated skylight area. Neither apply to
+ # cases with skylight wells.
tight = bm2 < roof.grossArea / 2 ? true : false
+ thin = depth.round(2) < (1.5 * wl).round(2) ? true : false
set = {}
set[:box ] = box
set[:bm2 ] = bm2
set[:tight ] = tight
+ set[:thin ] = thin
set[:roof ] = roof
set[:space ] = space
+ set[:m ] = space.multiplier
set[:sidelit] = rooms[space][:sidelit]
- set[:t ] = rooms[space][:t ]
- set[:sloped ] = slopedRoof?(roof)
+ set[:t0 ] = rooms[space][:t0]
+ set[:t ] = OpenStudio::Transformation.alignFace(roof.vertices)
sets << set
end
end
# Process outdoor-facing roof surfaces of plenums and attics above.
rooms.each do |space, room|
- t0 = room[:t]
- toits = getRoofs(space)
- rufs = room.key?(:roofs) ? toits - room[:roofs] : toits
- next if rufs.empty?
+ t0 = room[:t0]
+ rufs = getRoofs(space) - room[:roofs]
- # Process room ceilings, as 1x or more are overlapping roofs above. Fetch
- # vertically-cast overlaps.
rufs.each do |ruf|
+ next unless roof?(ruf)
+
espace = ruf.space
next if espace.empty?
espace = espace.get
next if espace.partofTotalFloorArea
- m = espace.multiplier
- ti = transforms(espace)
- next unless ti[:t]
+ m = espace.multiplier
- ti = ti[:t]
- vtx = ruf.vertices
-
- # Ensure BLC vertex sequence.
- if facingUp?(vtx)
- vtx = ti * vtx
-
- if xyz?(vtx, :z)
- vtx = blc(vtx)
- else
- dZ = vtx.first.z
- vtz = blc(flatten(vtx)).to_a
- vtx = []
-
- vtz.each { |v| vtx << OpenStudio::Point3d.new(v.x, v.y, v.z + dZ) }
- end
-
- ruf.setVertices(ti.inverse * vtx)
- else
- tr = OpenStudio::Transformation.alignFace(vtx)
- vtx = blc(tr.inverse * vtx)
- ruf.setVertices(tr * vtx)
+ if m != space.multiplier
+ log(ERR, "Skipping #{ruf.nameString} (multiplier mismatch) (#{mth})")
+ next
end
- ri = ti * ruf.vertices
+ ti = transforms(espace)
+ next unless ti[:t]
- facets(space, "Surface", "RoofCeiling").each do |tile|
- vtx = tile.vertices
+ ti = ti[:t]
+ rpts = ti * ruf.vertices
- # Ensure BLC vertex sequence.
- if facingUp?(vtx)
- vtx = t0 * vtx
-
- if xyz?(vtx, :z)
- vtx = blc(vtx)
- else
- dZ = vtx.first.z
- vtz = blc(flatten(vtx)).to_a
- vtx = []
-
- vtz.each { |v| vtx << OpenStudio::Point3d.new(v.x, v.y, v.z + dZ) }
- end
-
- vtx = t0.inverse * vtx
- else
- tr = OpenStudio::Transformation.alignFace(vtx)
- vtx = blc(tr.inverse * vtx)
- vtx = tr * vtx
- end
-
- tile.setVertices(vtx)
-
- ci0 = cast(t0 * tile.vertices, ri, ray)
+ # Process occupied room ceilings, as 1x or more are overlapping roof
+ # surfaces above. Vertically cast, then fetch overlap.
+ facets(space, "Surface", "RoofCeiling").each do |tile|
+ tpts = t0 * tile.vertices
+ ci0 = cast(tpts, rpts, ray)
next if ci0.empty?
- olap = overlap(ri, ci0, false)
+ olap = overlap(rpts, ci0)
next if olap.empty?
+ om2 = OpenStudio.getArea(olap)
+ next if om2.empty?
+
+ om2 = om2.get
+ next if om2.round(2) < w02.round(2)
+
box = boundedBox(olap)
next if box.empty?
# Adding skylight wells (plenums/attics) is contingent to safely
- # linking new base roof 'inserts' through leader lines. Currently,
- # this requires an offset from main roof surface edges.
+ # linking new base roof 'inserts' (as well as new ceiling ones)
+ # through 'leader lines'. This requires an offset to ensure no
+ # conflicts with roof or (ceiling) tile edges.
#
- # TO DO: expand the method to factor in cases where simple 'side'
+ # @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)
- box = poly(box, false, false, false, false, :blc)
next if box.empty?
bm2 = OpenStudio.getArea(box)
next if bm2.empty?
bm2 = bm2.get
- next if bm2.round(2) < w02.round(2)
+ next if bm2.round(2) < wl2.round(2)
- # Vertically-cast box onto ceiling below.
- cbox = cast(box, t0 * tile.vertices, ray)
+ width = alignedWidth(box, true)
+ depth = alignedHeight(box, true)
+ next if width < wl * 3
+ next if depth < wl * 2
+
+ # Vertically cast box onto tile below.
+ cbox = cast(box, tpts, ray)
next if cbox.empty?
cm2 = OpenStudio.getArea(cbox)
next if cm2.empty?
- cm2 = cm2.get
+ cm2 = cm2.get
+ box = ti.inverse * box
+ cbox = t0.inverse * cbox
- # Track if bounded boxes are significantly smaller than either roof
- # or ceiling.
- tight = bm2 < ruf.grossArea / 2 ? true : false
- tight = cm2 < tile.grossArea / 2 ? true : tight
-
unless ceilings.key?(tile)
floor = tile.adjacentSurface
if floor.empty?
log(ERR, "#{tile.nameString} adjacent floor? (#{mth})")
next
end
floor = floor.get
- # Ensure BLC vertex sequence.
- vtx = t0 * vtx
- floor.setVertices(ti.inverse * vtx.reverse)
-
if floor.space.empty?
log(ERR, "#{floor.nameString} space? (#{mth})")
next
end
@@ -6426,47 +6794,55 @@
unless espce == espace
log(ERR, "#{espce.nameString} != #{espace.nameString}? (#{mth})")
next
end
- ceilings[tile] = {}
- ceilings[tile][:roofs] = []
- ceilings[tile][:space] = space
- ceilings[tile][:floor] = floor
+ ceilings[tile] = {}
+ ceilings[tile][:roofs ] = []
+ ceilings[tile][:space ] = space
+ ceilings[tile][:floor ] = floor
end
ceilings[tile][:roofs] << ruf
- # More detailed skylight set entries with suspended ceilings.
+ # Skylight set key:values are more detailed with suspended ceilings.
+ # The overlap (:olap) remains in 'transformed' site coordinates (with
+ # regards to the roof). The :box polygon reverts to attic/plenum space
+ # coordinates, while the :cbox polygon is reset with regards to the
+ # occupied space coordinates.
set = {}
set[:olap ] = olap
set[:box ] = box
set[:cbox ] = cbox
+ set[:om2 ] = om2
set[:bm2 ] = bm2
set[:cm2 ] = cm2
- set[:tight ] = tight
+ set[:tight ] = false
+ set[:thin ] = false
set[:roof ] = ruf
set[:space ] = space
+ set[:m ] = space.multiplier
set[:clng ] = tile
- set[:t ] = t0
+ set[:t0 ] = t0
+ set[:ti ] = ti
+ set[:t ] = OpenStudio::Transformation.alignFace(ruf.vertices)
set[:sidelit] = room[:sidelit]
- set[:sloped ] = slopedRoof?(ruf)
if unconditioned?(espace) # e.g. attic
unless attics.key?(espace)
- attics[espace] = {t: ti, m: m, bm2: 0, roofs: []}
+ attics[espace] = {ti: ti, m: m, bm2: 0, roofs: []}
end
attics[espace][:bm2 ] += bm2
attics[espace][:roofs] << ruf
set[:attic] = espace
ceilings[tile][:attic] = espace
else # e.g. plenum
unless plenums.key?(espace)
- plenums[espace] = {t: ti, m: m, bm2: 0, roofs: []}
+ plenums[espace] = {ti: ti, m: m, bm2: 0, roofs: []}
end
plenums[espace][:bm2 ] += bm2
plenums[espace][:roofs] << ruf
@@ -6479,25 +6855,24 @@
break # only 1x unique ruf/ceiling pair.
end
end
end
- # Ensure uniqueness of plenum roofs, and set GROSS ROOF AREA.
+ # Ensure uniqueness of plenum roofs.
attics.values.each do |attic|
attic[:roofs ].uniq!
- attic[:ridges] = getHorizontalRidges(attic[:roofs]) # TO-DO
+ attic[:ridges] = getHorizontalRidges(attic[:roofs]) # @todo
end
plenums.values.each do |plenum|
plenum[:roofs ].uniq!
- # plenum[:m2 ] = plenum[:roofs].sum(&:grossArea)
- plenum[:ridges] = getHorizontalRidges(plenum[:roofs]) # TO-DO
+ plenum[:ridges] = getHorizontalRidges(plenum[:roofs]) # @todo
end
- # Regardless of the selected skylight arrangement pattern, the current
- # solution may only consider attic/plenum sets that can be successfully
- # linked to leader line anchors, for both roof and ceiling surfaces.
+ # Regardless of the selected skylight arrangement pattern, the solution only
+ # considers attic/plenum sets that can be successfully linked to leader line
+ # anchors, for both roof and ceiling surfaces. First, attic/plenum roofs.
[attics, plenums].each do |greniers|
k = greniers == attics ? :attic : :plenum
greniers.each do |spce, grenier|
grenier[:roofs].each do |roof|
@@ -6510,11 +6885,12 @@
sts = sts.select { |st| st.key?(:space) }
sts = sts.select { |st| st[k ] == spce }
sts = sts.select { |st| st[:roof] == roof }
next if sts.empty?
- sts = sts.sort_by { |st| st[:bm2] }
+ sts = sts.sort_by { |st| st[:bm2] }.reverse
+
genAnchors(roof, sts, :box)
end
end
end
@@ -6525,11 +6901,11 @@
ceilings.each do |tile, ceiling|
k = ceiling.key?(:attic) ? :attic : :plenum
next unless ceiling.key?(k)
space = ceiling[:space]
- spce = ceiling[k ]
+ spce = ceiling[k]
next unless ceiling.key?(:roofs)
next unless rooms.key?(space)
stz = []
@@ -6551,112 +6927,76 @@
stz << sts.first
end
next if stz.empty?
+ stz = stz.sort_by { |st| st[:cm2] }.reverse
genAnchors(tile, stz, :cbox)
end
# Delete voided sets.
sets.reject! { |set| set.key?(:void) }
- m2 = 0 # existing skylight rough opening area
- rm2 = grossRoofArea(spaces)
+ return empty("sets", mth, WRN, rm2) if sets.empty?
- # Tally existing skylight rough opening areas (%SRR calculations).
- rooms.values.each do |room|
- m = room[:m]
+ # Sort sets, from largest to smallest bounded box area.
+ sets = sets.sort_by { |st| st[:bm2] * st[:m] }.reverse
- room[:roofs].each do |roof|
- roof.subSurfaces.each do |sub|
- next unless fenestration?(sub)
-
- id = sub.nameString
- xm2 = sub.grossArea
-
- if sub.allowWindowPropertyFrameAndDivider
- unless sub.windowPropertyFrameAndDivider.empty?
- fw = sub.windowPropertyFrameAndDivider.get.frameWidth
- vec = offset(sub.vertices, fw, 300)
- aire = OpenStudio.getArea(vec)
-
- if aire.empty?
- log(ERR, "Skipping '#{id}': invalid Frame&Divider (#{mth})")
- else
- xm2 = aire.get
- end
- end
- end
-
- m2 += xm2 * sub.multiplier * m
- end
- end
- end
-
- # Required skylight area to add.
- sm2 = rm2 * srr - m2
-
- # Skip if existing skylights exceed or ~roughly match requested %SRR.
- if sm2.round(2) < w02.round(2)
- log(INF, "Skipping: existing srr > requested srr (#{mth})")
- return 0
- end
-
- # Any sidelit/sloped roofs being targeted?
- #
- # TODO: enable double-ridged, sloped roofs have double-sloped
- # skylights/wells (patterns "strip"/"strips").
+ # Any sidelit and/or sloped roofs being targeted?
+ # @todo: enable double-ridged, sloped roofs have double-sloped
+ # skylights/wells (patterns "strip"/"strips").
sidelit = sets.any? { |set| set[:sidelit] }
sloped = sets.any? { |set| set[:sloped ] }
+ # Average sandbox area + revised 'working' SRR%.
+ sbm2 = sets.map { |set| set[:bm2] }.reduce(:+)
+ avm2 = sbm2 / sets.size
+ srr2 = sm2 / sets.size / 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.:
+ # with regards to 'ideal' skylight placement, e.g.:
#
# aceee.org/files/proceedings/2004/data/papers/SS04_Panel3_Paper18.pdf
#
- # ... yet skylight areas are subsequently contracted to strictly meet SRR%.
+ # Skylight areas are subsequently contracted to strictly meet the target.
sets.each_with_index do |set, i|
- id = "set #{i+1}"
- well = set.key?(:clng)
- space = set[:space]
+ thin = set[:thin ]
tight = set[:tight]
factor = tight ? 1.75 : 1.25
+ well = set.key?(:clng)
+ space = set[:space]
room = rooms[space]
h = room[:h]
- t = OpenStudio::Transformation.alignFace(set[:box])
- abox = poly(set[:box], false, false, false, t, :ulc)
- obox = getRealignedFace(abox)
- next unless obox[:set]
+ width = alignedWidth( set[:box], true)
+ depth = alignedHeight(set[:box], true)
+ barea = set.key?(:om2) ? set[:om2] : set[:bm2]
+ rtio = barea / avm2
+ skym2 = srr2 * barea * rtio
- width = width(obox[:set])
- depth = height(obox[:set])
- area = width * depth
- skym2 = srr * area
-
- # Flag sets if too narrow/shallow to hold a single skylight.
+ # Flag set if too narrow/shallow to hold a single skylight.
if well
if width.round(2) < wl.round(2)
- log(ERR, "#{id}: Too narrow")
+ log(WRN, "set #{i+1} well: Too narrow (#{mth})")
set[:void] = true
next
end
if depth.round(2) < wl.round(2)
- log(ERR, "#{id}: Too shallow")
+ log(WRN, "set #{i+1} well: Too shallow (#{mth})")
set[:void] = true
next
end
else
if width.round(2) < w0.round(2)
- log(ERR, "#{id}: Too narrow")
+ log(WRN, "set #{i+1}: Too narrow (#{mth})")
set[:void] = true
next
end
if depth.round(2) < w0.round(2)
- log(ERR, "#{id}: Too shallow")
+ log(WRN, "set #{i+1}: Too shallow (#{mth})")
set[:void] = true
next
end
end
@@ -6665,44 +7005,39 @@
patterns.each do |pattern|
cols = 1
rows = 1
wx = w0
wy = w0
- wxl = wl
- wyl = wl
+ wxl = well ? wl : nil
+ wyl = well ? wl : nil
dX = nil
dY = nil
case pattern
when "array" # min 2x cols x min 2x rows
cols = 2
rows = 2
+ next if thin
if tight
sp = 1.4 * h / 2
- lx = well ? width - cols * wxl : width - cols * wx
- ly = well ? depth - rows * wyl : depth - rows * wy
+ lx = width - cols * wx
+ ly = depth - rows * wy
next if lx.round(2) < sp.round(2)
next if ly.round(2) < sp.round(2)
- if well
- cols = ((width - wxl) / (wxl + sp)).round(2).to_i + 1
- rows = ((depth - wyl) / (wyl + sp)).round(2).to_i + 1
- else
- cols = ((width - wx) / (wx + sp)).round(2).to_i + 1
- rows = ((depth - wy) / (wy + sp)).round(2).to_i + 1
- end
-
+ cols = ((width - wx) / (wx + sp)).round(2).to_i + 1
+ rows = ((depth - wy) / (wy + sp)).round(2).to_i + 1
next if cols < 2
next if rows < 2
- dX = well ? 0.0 : bfr + f
- dY = well ? 0.0 : bfr + f
+ dX = bfr + f
+ dY = bfr + f
else
sp = 1.4 * h
lx = well ? (width - cols * wxl) / cols : (width - cols * wx) / cols
- ly = well ? (depth - rows * wyl) / rows : (depth - rows * wy) / cols
+ ly = well ? (depth - rows * wyl) / rows : (depth - rows * wy) / rows
next if lx.round(2) < sp.round(2)
next if ly.round(2) < sp.round(2)
if well
cols = (width / (wxl + sp)).round(2).to_i
@@ -6713,250 +7048,242 @@
end
next if cols < 2
next if rows < 2
- ly = well ? (depth - rows * wyl) / rows : (depth - rows * wy) / cols
+ ly = well ? (depth - rows * wyl) / rows : (depth - rows * wy) / rows
dY = ly / 2
end
- # Current skylight area. If undershooting, adjust skylight width/depth
- # as well as reduce spacing. For geometrical constrained cases,
- # undershooting means not reaching 1.75x the required SRR%. Otherwise,
- # undershooting means not reaching 1.25x the required SRR%. Any
- # consequent overshooting is later corrected.
- tm2 = wx * cols * wy * rows
- undershot = tm2.round(2) < factor * skym2.round(2) ? true : false
+ # 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 SRR%.
- if undershot
+ # Inflate skylight width/depth (and reduce spacing) to reach target.
+ if tm2.round(2) < factor * skym2.round(2)
ratio2 = 1 + (factor * skym2 - tm2) / tm2
ratio = Math.sqrt(ratio2)
- sp = w
+ sp = wl
wx *= ratio
wy *= ratio
- wxl = wx + gap
- wyl = wy + gap
+ wxl = wx + gap if well
+ wyl = wy + gap if well
if tight
- if well
- lx = (width - cols * wxl) / (cols - 1)
- ly = (depth - rows * wyl) / (rows - 1)
- else
- lx = (width - cols * wx) / (cols - 1)
- ly = (depth - rows * wy) / (rows - 1)
- end
-
+ lx = (width - 2 * (bfr + f) - cols * wx) / (cols - 1)
+ ly = (depth - 2 * (bfr + f) - rows * wy) / (rows - 1)
lx = lx.round(2) < sp.round(2) ? sp : lx
ly = ly.round(2) < sp.round(2) ? sp : ly
-
- if well
- wxl = (width - (cols - 1) * lx) / cols
- wyl = (depth - (rows - 1) * ly) / rows
- wx = wxl - gap
- wy = wyl - gap
- else
- wx = (width - (cols - 1) * lx) / cols
- wy = (depth - (rows - 1) * ly) / rows
- wxl = wx + gap
- wyl = wy + gap
- end
+ 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
- else
- lx = (width - cols * wx) / cols
- ly = (depth - rows * wy) / rows
- end
-
- lx = lx.round(2) < sp.round(2) ? sp : lx
- ly = ly.round(2) < sp.round(2) ? sp : ly
-
- if well
+ lx = (width - cols * wxl) / cols
+ ly = (depth - rows * wyl) / rows
+ lx = lx.round(2) < sp.round(2) ? sp : lx
+ ly = ly.round(2) < sp.round(2) ? sp : ly
wxl = (width - cols * lx) / cols
wyl = (depth - rows * ly) / rows
wx = wxl - gap
wy = wyl - gap
- lx = (width - cols * wxl) / cols
ly = (depth - rows * wyl) / rows
else
+ lx = (width - cols * wx) / cols
+ ly = (depth - rows * wy) / rows
+ lx = lx.round(2) < sp.round(2) ? sp : lx
+ ly = ly.round(2) < sp.round(2) ? sp : ly
wx = (width - cols * lx) / cols
wy = (depth - rows * ly) / rows
- wxl = wx + gap
- wyl = wy + gap
- lx = (width - cols * wx) / cols
ly = (depth - rows * wy) / rows
end
- end
- dY = ly / 2
+ dY = ly / 2
+ end
end
when "strips" # min 2x cols x 1x row
cols = 2
if tight
sp = h / 2
- lx = well ? width - cols * wxl : width - cols * wx
- ly = well ? depth - wyl : depth - wy
+ dX = bfr + f
+ lx = width - cols * wx
next if lx.round(2) < sp.round(2)
- next if ly.round(2) < sp.round(2)
- if well
- cols = ((width - wxl) / (wxl + sp)).round(2).to_i + 1
- else
- cols = ((width - wx) / (wx + sp)).round(2).to_i + 1
- end
-
+ cols = ((width - wx) / (wx + sp)).round(2).to_i + 1
next if cols < 2
- if well
- wyl = depth - ly
- wy = wyl - gap
+ if thin
+ dY = bfr + f
+ wy = depth - 2 * dY
+ next if wy.round(2) < gap4
else
- wy = depth - ly
- wyl = wy + gap
- end
+ ly = depth - wy
+ next if ly.round(2) < wl.round(2)
- dX = well ? 0 : bfr + f
- dY = ly / 2
+ dY = ly / 2
+ end
else
sp = h
- lx = well ? (width - cols * wxl) / cols : (width - cols * wx) / cols
- ly = well ? depth - wyl : depth - wy
- next if lx.round(2) < sp.round(2)
- next if ly.round(2) < w.round(2)
if well
+ lx = (width - cols * wxl) / cols
+ next if lx.round(2) < sp.round(2)
+
cols = (width / (wxl + sp)).round(2).to_i
+ next if cols < 2
+
+ ly = depth - wyl
+ dY = ly / 2
+ next if ly.round(2) < wl.round(2)
else
+ lx = (width - cols * wx) / cols
+ next if lx.round(2) < sp.round(2)
+
cols = (width / (wx + sp)).round(2).to_i
- end
+ next if cols < 2
- next if cols < 2
+ if thin
+ dY = bfr + f
+ wy = depth - 2 * dY
+ next if wy.round(2) < gap4
+ else
+ ly = depth - wy
+ next if ly.round(2) < wl.round(2)
- if well
- wyl = depth - ly
- wy = wyl - gap
- else
- wy = depth - ly
- wyl = wy + gap
+ dY = ly / 2
+ end
end
-
- dY = ly / 2
end
- tm2 = wx * cols * wy
- undershot = tm2.round(2) < factor * skym2.round(2) ? true : false
+ tm2 = wx * cols * wy
- # Inflate skylight width (and reduce spacing) to reach SRR%.
- if undershot
- ratio2 = 1 + (factor * skym2 - tm2) / tm2
+ # Inflate skylight depth to reach target.
+ if tm2.round(2) < factor * skym2.round(2)
+ sp = wl
- sp = w
- wx *= ratio2
- wxl = wx + gap
+ # Skip if already thin.
+ unless thin
+ ratio2 = 1 + (factor * skym2 - tm2) / tm2
- if tight
+ wy *= ratio2
+
if well
- lx = (width - cols * wxl) / (cols - 1)
+ wyl = wy + gap
+ ly = depth - wyl
+ ly = ly.round(2) < sp.round(2) ? sp : ly
+ wyl = depth - ly
+ wy = wyl - gap
else
- lx = (width - cols * wx) / (cols - 1)
+ ly = depth - wy
+ ly = ly.round(2) < sp.round(2) ? sp : ly
+ wy = depth - ly
end
- lx = lx.round(2) < sp.round(2) ? sp : lx
+ dY = ly / 2
+ end
+ end
- if well
- wxl = (width - (cols - 1) * lx) / cols
- wx = wxl - gap
- else
- wx = (width - (cols - 1) * lx) / cols
- wxl = wx + gap
- end
- else
- if well
- lx = (width - cols * wxl) / cols
- else
- lx = (width - cols * wx) / cols
- end
+ tm2 = wx * cols * wy
- lx = lx.round(2) < sp.round(2) ? sp : lx
+ # Inflate skylight width (and reduce spacing) to reach target.
+ if tm2.round(2) < factor * skym2.round(2)
+ ratio2 = 1 + (factor * skym2 - tm2) / tm2
+ wx *= ratio2
+ wxl = wx + gap if well
+
+ if tight
+ lx = (width - 2 * (bfr + f) - cols * wx) / (cols - 1)
+ lx = lx.round(2) < sp.round(2) ? sp : lx
+ wx = (width - 2 * (bfr + f) - (cols - 1) * lx) / cols
+ else
if well
+ lx = (width - cols * wxl) / cols
+ lx = lx.round(2) < sp.round(2) ? sp : lx
wxl = (width - cols * lx) / cols
wx = wxl - gap
- lx = (width - cols * wxl) / cols
else
- wx = (width - cols * lx) / cols
- wxl = wx + gap
lx = (width - cols * wx) / cols
+ lx = lx.round(2) < sp.round(2) ? sp : lx
+ wx = (width - cols * lx) / cols
end
end
end
else # "strip" 1 (long?) row x 1 column
- sp = w
- lx = well ? width - wxl : width - wx
- ly = well ? depth - wyl : depth - wy
-
if tight
- next if lx.round(2) < sp.round(2)
- next if ly.round(2) < sp.round(2)
+ sp = gap4
+ dX = bfr + f
+ wx = width - 2 * dX
+ next if wx.round(2) < sp.round(2)
- if well
- wxl = width - lx
- wyl = depth - ly
- wx = wxl - gap
- wy = wyl - gap
+ if thin
+ dY = bfr + f
+ wy = depth - 2 * dY
+ next if wy.round(2) < sp.round(2)
else
- wx = width - lx
- wy = depth - ly
- wxl = wx + gap
- wyl = wy + gap
+ ly = depth - wy
+ dY = ly / 2
+ next if ly.round(2) < sp.round(2)
end
-
- dX = well ? 0.0 : bfr + f
- dY = ly / 2
else
+ sp = wl
+ lx = well ? width - wxl : width - wx
+ ly = well ? depth - wyl : depth - wy
+ dY = ly / 2
next if lx.round(2) < sp.round(2)
next if ly.round(2) < sp.round(2)
+ end
- if well
- wxl = width - lx
- wyl = depth - ly
- wx = wxl - gap
- wy = wyl - gap
- else
- wx = width - lx
- wy = depth - ly
- wxl = wx + gap
- wyl = wy + gap
- end
+ tm2 = wx * wy
- dY = ly / 2
+ # Inflate skylight width (and reduce spacing) to reach target.
+ if tm2.round(2) < factor * skym2.round(2)
+ unless tight
+ ratio2 = 1 + (factor * skym2 - tm2) / tm2
+
+ wx *= ratio2
+
+ if well
+ wxl = wx + gap
+ lx = width - wxl
+ lx = lx.round(2) < sp.round(2) ? sp : lx
+ wxl = width - lx
+ wx = wxl - gap
+ else
+ lx = width - wx
+ lx = lx.round(2) < sp.round(2) ? sp : lx
+ wx = width - lx
+ end
+ end
end
- tm2 = wx * wy
- undershot = tm2.round(2) < factor * skym2.round(2) ? true : false
+ tm2 = wx * wy
- # Inflate skylight depth to reach SRR%.
- if undershot
- ratio2 = 1 + (factor * skym2 - tm2) / tm2
+ # Inflate skylight depth to reach target. Skip if already tight thin.
+ if tm2.round(2) < factor * skym2.round(2)
+ unless thin
+ ratio2 = 1 + (factor * skym2 - tm2) / tm2
- sp = w
- wy *= ratio2
- wyl = wy + gap
+ wy *= ratio2
- ly = well ? depth - wy : depth - wyl
- ly = ly.round(2) < sp.round(2) ? sp : lx
+ if well
+ wyl = wy + gap
+ ly = depth - wyl
+ ly = ly.round(2) < sp.round(2) ? sp : ly
+ wyl = depth - ly
+ wy = wyl - gap
+ else
+ ly = depth - wy
+ ly = ly.round(2) < sp.round(2) ? sp : ly
+ wy = depth - ly
+ end
- if well
- wyl = depth - ly
- wy = wyl - gap
- else
- wy = depth - ly
- wyl = wy + gap
+ dY = ly / 2
end
end
end
st = {}
@@ -6970,33 +7297,36 @@
st[:dX ] = dX if dX
st[:dY ] = dY if dY
set[pattern] = st
end
+
+ set[:void] = true unless patterns.any? { |k| set.key?(k) }
end
# Delete voided sets.
sets.reject! { |set| set.key?(:void) }
+ return empty("sets (2)", mth, WRN, rm2) if sets.empty?
# Final reset of filters.
filters.map! { |f| f.include?("b") ? f.delete("b") : f } unless sidelit
filters.map! { |f| f.include?("c") ? f.delete("c") : f } unless sloped
filters.map! { |f| f.include?("d") ? f.delete("d") : f } if plenums.empty?
filters.map! { |f| f.include?("e") ? f.delete("e") : f } if attics.empty?
filters.reject! { |f| f.empty? }
filters.uniq!
- # Initialize skylight area tally.
+ # Initialize skylight area tally (to increment).
skm2 = 0
# Assign skylight pattern.
- filters.each_with_index do |filter, i|
+ filters.each do |filter|
next if skm2.round(2) >= sm2.round(2)
+ dm2 = sm2 - skm2 # differential (remaining skylight area to meet).
sts = sets
- sts = sts.sort_by { |st| st[:bm2] }.reverse!
sts = sts.reject { |st| st.key?(:pattern) }
if filter.include?("a")
# Start with the default (ideal) allocation selection:
# - large roof surface areas (e.g. retail, classrooms not corridors)
@@ -7027,37 +7357,53 @@
wx = st[pattern][:wx ]
wy = st[pattern][:wy ]
fpm2[pattern] = {m2: 0, tight: false} unless fpm2.key?(pattern)
- fpm2[pattern][:m2 ] += wx * wy * cols * rows
- fpm2[pattern][:tight] = st[:tight] ? true : false
+ fpm2[pattern][:m2 ] += st[:m] * wx * wy * cols * rows
+ fpm2[pattern][:tight] = true if st[:tight]
end
end
pattern = nil
next if fpm2.empty?
- fpm2 = fpm2.sort_by { |_, fm2| fm2[:m2] }.to_h
-
- # Select suitable pattern, often overshooting. Favour array unless
- # geometrically constrainted.
+ # Favour (large) arrays if meeting residual target, unless constrained.
if fpm2.keys.include?("array")
- if (fpm2["array"][:m2]).round(2) >= sm2.round(2)
+ if fpm2["array"][:m2].round(2) >= dm2.round(2)
pattern = "array" unless fpm2[:tight]
end
end
unless pattern
- if fpm2.values.first[:m2].round(2) >= sm2.round(2)
- pattern = fpm2.keys.first
- elsif fpm2.values.last[:m2].round(2) <= sm2.round(2)
- pattern = fpm2.keys.last
+ fpm2 = fpm2.sort_by { |_, fm2| fm2[:m2] }.to_h
+ min_m2 = fpm2.values.first[:m2]
+ max_m2 = fpm2.values.last[:m2]
+
+ if min_m2.round(2) >= dm2.round(2)
+ # If not large array, then retain pattern generating smallest skylight
+ # area if ALL patterns >= residual target (deterministic sorting).
+ fpm2.keep_if { |_, fm2| fm2[:m2].round(2) == min_m2.round(2) }
+
+ if fpm2.keys.include?("array")
+ pattern = "array"
+ elsif fpm2.keys.include?("strips")
+ pattern = "strips"
+ else fpm2.keys.include?("strip")
+ pattern = "strip"
+ end
else
- fpm2.keep_if { |_, fm2| fm2[:m2].round(2) >= sm2.round(2) }
+ # Pick pattern offering greatest skylight area (deterministic sorting).
+ fpm2.keep_if { |_, fm2| fm2[:m2].round(2) == max_m2.round(2) }
- pattern = fpm2.keys.first
+ if fpm2.keys.include?("strip")
+ pattern = "strip"
+ elsif fpm2.keys.include?("strips")
+ pattern = "strips"
+ else fpm2.keys.include?("array")
+ pattern = "array"
+ end
end
end
skm2 += fpm2[pattern][:m2]
@@ -7084,59 +7430,166 @@
set[:dY ] = set[pattern][:dY ] if set[pattern][:dY]
end
end
end
- # Skylight size contraction if overshot (e.g. -13.2% if overshot by +13.2%).
- # This is applied on a surface/pattern basis; individual skylight sizes may
- # vary from one surface to the next, depending on respective patterns.
+ # Delete incomplete sets (same as rejected if 'voided').
+ sets.reject! { |set| set.key?(:void) }
+ sets.select! { |set| set.key?(:pattern) }
+ return empty("sets (3)", mth, WRN, rm2) if sets.empty?
+
+ # Skylight size contraction if overshot (e.g. scale down by -13% if > +13%).
+ # Applied on a surface/pattern basis: individual skylight sizes may vary
+ # from one surface to the next, depending on respective patterns.
+
+ # First, skip whole sets altogether if their total m2 < (skm2 - sm2). Only
+ # considered if significant discrepancies vs average set skylight m2.
+ sbm2 = 0
+
+ sets.each do |set|
+ sbm2 += set[:cols] * set[:w] * set[:rows] * set[:d] * set[:m]
+ end
+
+ avm2 = sbm2 / sets.size
+
if skm2.round(2) > sm2.round(2)
+ sets.reverse.each do |set|
+ break unless skm2.round(2) > sm2.round(2)
+
+ stm2 = set[:cols] * set[:w] * set[:rows] * set[:d] * set[:m]
+ next unless stm2 < 0.75 * avm2
+ next unless stm2.round(2) < (skm2 - sm2).round(2)
+
+ skm2 -= stm2
+ set[:void] = true
+ end
+ end
+
+ sets.reject! { |set| set.key?(:void) }
+ return empty("sets (4)", mth, WRN, rm2) if sets.empty?
+
+ # Size contraction: round 1: low-hanging fruit.
+ if skm2.round(2) > sm2.round(2)
ratio2 = 1 - (skm2 - sm2) / skm2
ratio = Math.sqrt(ratio2)
- skm2 *= ratio2
sets.each do |set|
- next if set.key?(:void)
- next unless set.key?(:pattern)
+ am2 = set[:cols] * set[:w] * set[:rows] * set[:d] * set[:m]
+ xr = set[:w]
+ yr = set[:d]
- pattern = set[:pattern]
- next unless set.key?(pattern)
+ if xr > w0
+ xr = xr * ratio < w0 ? w0 : xr * ratio
+ end
- case pattern
- when "array" # equally adjust both width and depth
- xr = set[:w] * ratio
- yr = set[:d] * ratio
- dyr = set[:d] - yr
+ if yr > w0
+ yr = yr * ratio < w0 ? w0 : yr * ratio
+ end
- set[:w ] = xr
- set[:d ] = yr
- set[:w0] = set[:w] + gap
- set[:d0] = set[:d] + gap
- set[:dY] += dyr / 2
- when "strips" # adjust depth
- xr2 = set[:w] * ratio2
+ xm2 = set[:cols] * xr * set[:rows] * yr * set[:m]
+ next if xm2.round(2) == am2.round(2)
- set[:w ] = xr2
- set[:w0] = set[:w] + gap
- else # "strip", adjust width
- yr2 = set[:d] * ratio2
- dyr = set[:d] - yr2
+ set[:dY] += (set[:d] - yr) / 2
+ set[:dX] += (set[:w] - xr) / 2 if set.key?(:dX)
+ set[:w ] = xr
+ set[:d ] = yr
+ set[:w0] = set[:w] + gap
+ set[:d0] = set[:d] + gap
- set[:d ] = yr2
- set[:d0] = set[:w] + gap
- set[:dY] += dyr / 2
+ skm2 -= (am2 - xm2)
+ end
+ end
+
+ # Size contraction: round 2: prioritize larger sets.
+ adm2 = 0
+
+ sets.each_with_index do |set, i|
+ next if set[:w].round(2) <= w0
+ next if set[:d].round(2) <= w0
+
+ adm2 += set[:cols] * set[:w] * set[:rows] * set[:d] * set[:m]
+ end
+
+ if skm2.round(2) > sm2.round(2) && adm2.round(2) > sm2.round(2)
+ ratio2 = 1 - (adm2 - sm2) / adm2
+ ratio = Math.sqrt(ratio2)
+
+ sets.each do |set|
+ next if set[:w].round(2) <= w0
+ next if set[:d].round(2) <= w0
+
+ am2 = set[:cols] * set[:w] * set[:rows] * set[:d] * set[:m]
+ xr = set[:w]
+ yr = set[:d]
+
+ if xr > w0
+ xr = xr * ratio < w0 ? w0 : xr * ratio
end
+
+ if yr > w0
+ yr = yr * ratio < w0 ? w0 : yr * ratio
+ end
+
+ xm2 = set[:cols] * xr * set[:rows] * yr * set[:m]
+ next if xm2.round(2) == am2.round(2)
+
+ set[:dY] += (set[:d] - yr) / 2
+ set[:dX] += (set[:w] - xr) / 2 if set.key?(:dX)
+ set[:w ] = xr
+ set[:d ] = yr
+ set[:w0] = set[:w] + gap
+ set[:d0] = set[:d] + gap
+
+ skm2 -= (am2 - xm2)
+ adm2 -= (am2 - xm2)
end
end
- # Generate skylight well roofs for attics & plenums.
+ # Size contraction: round 3: Resort to sizes < requested w0.
+ if skm2.round(2) > sm2.round(2)
+ ratio2 = 1 - (skm2 - sm2) / skm2
+ ratio = Math.sqrt(ratio2)
+
+ sets.each do |set|
+ break unless skm2.round(2) > sm2.round(2)
+
+ am2 = set[:cols] * set[:w] * set[:rows] * set[:d] * set[:m]
+ xr = set[:w]
+ yr = set[:d]
+
+ if xr > gap4
+ xr = xr * ratio < gap4 ? gap4 : xr * ratio
+ end
+
+ if yr > gap4
+ yr = yr * ratio < gap4 ? gap4 : yr * ratio
+ end
+
+ xm2 = set[:cols] * xr * set[:rows] * yr * set[:m]
+ next if xm2.round(2) == am2.round(2)
+
+ set[:dY] += (set[:d] - yr) / 2
+ set[:dX] += (set[:w] - xr) / 2 if set.key?(:dX)
+ set[:w ] = xr
+ set[:d ] = yr
+ set[:w0] = set[:w] + gap
+ set[:d0] = set[:d] + gap
+
+ skm2 -= (am2 - xm2)
+ end
+ end
+
+ # Log warning if unable to entirely contract skylight dimensions.
+ if skm2.round(2) > sm2.round(2)
+ log(WRN, "Skylights slightly oversized (#{mth})")
+ end
+
+ # Generate skylight well vertices for roofs, attics & plenums.
[attics, plenums].each do |greniers|
k = greniers == attics ? :attic : :plenum
greniers.each do |spce, grenier|
- ti = grenier[:t]
-
grenier[:roofs].each do |roof|
sts = sets
sts = sts.select { |st| st.key?(k) }
sts = sts.select { |st| st.key?(:pattern) }
sts = sts.select { |st| st.key?(:clng) }
@@ -7148,19 +7601,19 @@
sts = sts.select { |st| rooms.key?(st[:space]) }
sts = sts.select { |st| st.key?(:ld) }
sts = sts.select { |st| st[:ld].key?(roof) }
next if sts.empty?
- # If successful, 'genInserts' returns extended roof surface vertices,
- # including leader lines to support cutouts. The final selection is
+ # 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). The method also
- # generates new roof inserts. See key:value pair :vts.
+ # inserts (vis-à-vis attic/plenum floor below).
vz = genInserts(roof, sts)
- next if vz.empty? # TODO log error if empty
+ next if vz.empty?
- roof.setVertices(ti.inverse * vz)
+ roof.setVertices(vz)
end
end
end
# Repeat for ceilings below attic/plenum floors.
@@ -7176,18 +7629,19 @@
next unless rooms.key?(space)
next unless greniers.key?(spce)
room = rooms[space]
grenier = greniers[spce]
- ti = grenier[:t]
- t0 = room[:t]
+ ti = grenier[:ti]
+ t0 = room[:t0]
stz = []
ceiling[:roofs].each do |roof|
sts = sets
sts = sts.select { |st| st.key?(k) }
+ sts = sts.select { |st| st.key?(:pattern) }
sts = sts.select { |st| st.key?(:clng) }
sts = sts.select { |st| st.key?(:cm2) }
sts = sts.select { |st| st.key?(:roof) }
sts = sts.select { |st| st.key?(:space) }
sts = sts.select { |st| st[:clng] == tile }
@@ -7204,107 +7658,112 @@
stz << sts.first
end
next if stz.empty?
- # Vertically-cast set roof :vtx onto ceiling.
- stz.each do |st|
- cvtx = cast(ti * st[:vtx], t0 * tile.vertices, ray)
- st[:cvtx] = t0.inverse * cvtx
- end
-
- # Extended ceiling vertices.
- vertices = genExtendedVertices(tile, stz, :cvtx)
- next if vertices.empty?
-
- # Reset ceiling and adjacent floor vertices.
- tile.setVertices(t0.inverse * vertices)
- floor.setVertices(ti.inverse * vertices.to_a.reverse)
-
# Add new roof inserts & skylights for the (now) toplit space.
stz.each_with_index do |st, i|
- sub = {}
- sub[:type ] = "Skylight"
- sub[:width ] = st[:w] - f2
- sub[:height] = st[:d] - f2
- sub[:sill ] = gap / 2
- sub[:frame ] = frame if frame
+ sub = {}
+ sub[:type ] = "Skylight"
+ sub[:frame] = frame if frame
+ sub[:sill ] = gap / 2
st[:vts].each do |id, vt|
- roof = OpenStudio::Model::Surface.new(t0.inverse * vt, mdl)
+ roof = OpenStudio::Model::Surface.new(t0.inverse * (ti * vt), mdl)
roof.setSpace(space)
- roof.setName("#{i}:#{id}:#{space.nameString}")
+ roof.setName("#{id}:#{space.nameString}")
# Generate well walls.
- v0 = roof.vertices
vX = cast(roof, tile, ray)
- s0 = getSegments(v0)
- sX = getSegments(vX)
+ s0 = getSegments(t0 * roof.vertices)
+ sX = getSegments(t0 * vX)
s0.each_with_index do |sg, j|
sg0 = sg.to_a
sgX = sX[j].to_a
- vec = OpenStudio::Point3dVector.new
+ vec = OpenStudio::Point3dVector.new
vec << sg0.first
vec << sg0.last
vec << sgX.last
vec << sgX.first
- grenier_wall = OpenStudio::Model::Surface.new(vec, mdl)
+ v_grenier = ti.inverse * vec
+ v_room = (t0.inverse * vec).to_a.reverse
+
+ grenier_wall = OpenStudio::Model::Surface.new(v_grenier, mdl)
grenier_wall.setSpace(spce)
- grenier_wall.setName("#{id}:#{j}:#{spce.nameString}")
+ grenier_wall.setName("#{id}:#{i}:#{j}:#{spce.nameString}")
- room_wall = OpenStudio::Model::Surface.new(vec.to_a.reverse, mdl)
+ room_wall = OpenStudio::Model::Surface.new(v_room, mdl)
room_wall.setSpace(space)
- room_wall.setName("#{id}:#{j}:#{space.nameString}")
+ room_wall.setName("#{id}:#{i}:#{j}:#{space.nameString}")
grenier_wall.setAdjacentSurface(room_wall)
room_wall.setAdjacentSurface(grenier_wall)
end
- # Add individual skylights.
- addSubs(roof, [sub])
+ # Add individual skylights. Independently of the set layout (rows x
+ # cols), individual roof inserts may be deeper than wider (or
+ # vice-versa). Adapt skylight width vs depth accordingly.
+ if st[:d].round(2) > st[:w].round(2)
+ sub[:width ] = st[:d] - f2
+ sub[:height] = st[:w] - f2
+ else
+ sub[:width ] = st[:w] - f2
+ sub[:height] = st[:d] - f2
+ end
+
+ sub[:id] = roof.nameString
+ addSubs(roof, sub, false, true, true)
end
end
+
+ # Vertically-cast set roof :vtx onto ceiling.
+ stz.each do |st|
+ st[:cvtx] = t0.inverse * cast(ti * st[:vtx], t0 * tile.vertices, ray)
+ end
+
+ # Extended ceiling vertices.
+ vertices = genExtendedVertices(tile, stz, :cvtx)
+ next if vertices.empty?
+
+ # Reset ceiling and adjacent floor vertices.
+ tile.setVertices(vertices)
+ floor.setVertices(ti.inverse * (t0 * vertices).to_a.reverse)
end
- # New direct roof loop. No overlaps, so no need for relative space
- # coordinate adjustments.
+ # Loop through 'direct' roof surfaces of rooms to toplit (no attics or
+ # plenums). No overlaps, so no relative space coordinate adjustments.
rooms.each do |space, room|
room[:roofs].each do |roof|
- sets.each_with_index do |set, i|
- next if set.key?(:clng)
- next unless set.key?(:box)
- next unless set.key?(:roof)
- next unless set.key?(:cols)
- next unless set.key?(:rows)
- next unless set.key?(:d)
- next unless set.key?(:w)
- next unless set.key?(:tight)
- next unless set[:roof] == roof
+ sets.each_with_index do |st, i|
+ next unless st.key?(:roof)
+ next unless st[:roof] == roof
+ next if st.key?(:clng)
+ next unless st.key?(:box)
+ next unless st.key?(:cols)
+ next unless st.key?(:rows)
+ next unless st.key?(:d)
+ next unless st.key?(:w)
+ next unless st.key?(:dY)
- tight = set[:tight]
+ w1 = st[:w ] - f2
+ d1 = st[:d ] - f2
+ dY = st[:dY]
- d1 = set[:d] - f2
- w1 = set[:w] - f2
-
- # Y-axis 'height' of the roof, once re/aligned.
- # TODO: retrieve st[:out], +efficient
- y = alignedHeight(set[:box])
- dY = set[:dY] if set[:dY]
-
- set[:rows].times.each do |j|
+ st[:rows].times.each do |j|
sub = {}
sub[:type ] = "Skylight"
- sub[:count ] = set[:cols]
+ sub[:count ] = st[:cols]
sub[:width ] = w1
sub[:height ] = d1
sub[:frame ] = frame if frame
- sub[:id ] = "set #{i+1}:#{j+1}"
+ sub[:id ] = "#{roof.nameString}:#{i}:#{j}"
sub[:sill ] = dY + j * (2 * dY + d1)
- sub[:r_buffer] = set[:dX] if set[:dX]
- sub[:l_buffer] = set[:dX] if set[:dX]
- addSubs(roof, [sub])
+ sub[:r_buffer] = st[:dX] if st[:dX]
+ sub[:l_buffer] = st[:dX] if st[:dX]
+
+ addSubs(roof, sub, false, true, true)
end
end
end
end