diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index c4c828f..b0339fb 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -22,38 +22,6 @@ jobs: docker exec -t test bundle update docker exec -t test bundle exec rake docker kill test - test_330x: - runs-on: ubuntu-22.04 - steps: - - name: Check out repository - uses: actions/checkout@v2 - - name: Run Tests - run: | - echo $(pwd) - echo $(ls) - docker pull nrel/openstudio:3.3.0 - docker run --name test --rm -d -t -v $(pwd):/work -w /work nrel/openstudio:3.3.0 - docker exec -t test pwd - docker exec -t test ls - docker exec -t test bundle update - docker exec -t test bundle exec rake - docker kill test - test_340x: - runs-on: ubuntu-22.04 - steps: - - name: Check out repository - uses: actions/checkout@v2 - - name: Run Tests - run: | - echo $(pwd) - echo $(ls) - docker pull nrel/openstudio:3.4.0 - docker run --name test --rm -d -t -v $(pwd):/work -w /work nrel/openstudio:3.4.0 - docker exec -t test pwd - docker exec -t test ls - docker exec -t test bundle update - docker exec -t test bundle exec rake - docker kill test test_351x: runs-on: ubuntu-22.04 steps: @@ -134,3 +102,19 @@ jobs: docker exec -t test bundle update docker exec -t test bundle exec rake docker kill test + test_3100x: + runs-on: ubuntu-22.04 + steps: + - name: Check out repository + uses: actions/checkout@v2 + - name: Run Tests + run: | + echo $(pwd) + echo $(ls) + docker pull nrel/openstudio:3.10.0 + docker run --name test --rm -d -t -v $(pwd):/work -w /work nrel/openstudio:3.10.0 + docker exec -t test pwd + docker exec -t test ls + docker exec -t test bundle update + docker exec -t test bundle exec rake + docker kill test diff --git a/LICENSE b/LICENSE index f8d9959..781b8cd 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ BSD 3-Clause License -Copyright (c) 2022-2024, Denis Bourgeois +Copyright (c) 2022-2025, Denis Bourgeois All rights reserved. Redistribution and use in source and binary forms, with or without diff --git a/lib/osut.rb b/lib/osut.rb index 4772084..7561fec 100644 --- a/lib/osut.rb +++ b/lib/osut.rb @@ -1,6 +1,6 @@ # BSD 3-Clause License # -# Copyright (c) 2022-2024, Denis Bourgeois +# Copyright (c) 2022-2025, Denis Bourgeois # All rights reserved. # # Redistribution and use in source and binary forms, with or without diff --git a/lib/osut/utils.rb b/lib/osut/utils.rb index 4e560d8..35cb90c 100644 --- a/lib/osut/utils.rb +++ b/lib/osut/utils.rb @@ -1,6 +1,6 @@ # BSD 3-Clause License # -# Copyright (c) 2022-2024, Denis Bourgeois +# Copyright (c) 2022-2025, Denis Bourgeois # All rights reserved. # # Redistribution and use in source and binary forms, with or without @@ -220,13 +220,14 @@ def genConstruction(model = nil, specs = {}) chk = @@uo.keys.include?(specs[:type]) return invalid("surface type", mth, 2, ERR) unless chk - specs[:uo] = @@uo[ specs[:type] ] unless specs.key?(:uo) + specs[:uo] = @@uo[ specs[:type] ] unless specs.key?(:uo) # can be nil u = specs[:uo] if u - return mismatch("#{id} Uo", u, Numeric, mth) unless u.is_a?(Numeric) - return invalid("#{id} Uo (> 5.678)", mth, 2, ERR) if u > 5.678 - return negative("#{id} Uo" , mth, ERR) if u < 0 + return mismatch("#{id} Uo", u, Numeric, mth) unless u.is_a?(Numeric) + return invalid("#{id} Uo (> 5.678)", mth, 2, ERR) if u > 5.678 + return zero("#{id} Uo", mth, ERR) if u.round(2) == 0.00 + return negative("#{id} Uo", mth, ERR) if u < 0 end # Optional specs. Log/reset if invalid. @@ -466,14 +467,14 @@ def genConstruction(model = nil, specs = {}) a[:compo ][:d ] = d a[:compo ][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}" when :window - a[:glazing][:u ] = specs[:uo ] + a[:glazing][:u ] = u ? u : @@uo[:window] a[:glazing][:shgc] = 0.450 a[:glazing][:shgc] = specs[:shgc] if specs.key?(:shgc) a[:glazing][:id ] = "OSut|window" a[:glazing][:id ] += "|U#{format('%.1f', a[:glazing][:u])}" a[:glazing][:id ] += "|SHGC#{format('%d', a[:glazing][:shgc]*100)}" when :skylight - a[:glazing][:u ] = specs[:uo ] + a[:glazing][:u ] = u ? u : @@uo[:skylight] a[:glazing][:shgc] = 0.450 a[:glazing][:shgc] = specs[:shgc] if specs.key?(:shgc) a[:glazing][:id ] = "OSut|skylight" @@ -482,25 +483,11 @@ def genConstruction(model = nil, specs = {}) end # Initiate layers. - glazed = true - glazed = false if a[:glazing].empty? - layers = OpenStudio::Model::OpaqueMaterialVector.new unless glazed - layers = OpenStudio::Model::FenestrationMaterialVector.new if glazed + unglazed = a[:glazing].empty? ? true : false - if glazed - u = a[:glazing][:u ] - shgc = a[:glazing][:shgc] - lyr = model.getSimpleGlazingByName(a[:glazing][:id]) - - if lyr.empty? - lyr = OpenStudio::Model::SimpleGlazing.new(model, u, shgc) - lyr.setName(a[:glazing][:id]) - else - lyr = lyr.get - end + if unglazed + layers = OpenStudio::Model::OpaqueMaterialVector.new - layers << lyr - else # Loop through each layer spec, and generate construction. a.each do |i, l| next if l.empty? @@ -524,44 +511,68 @@ def genConstruction(model = nil, specs = {}) layers << lyr end + else + layers = OpenStudio::Model::FenestrationMaterialVector.new + + u0 = a[:glazing][:u ] + shgc = a[:glazing][:shgc] + lyr = model.getSimpleGlazingByName(a[:glazing][:id]) + + if lyr.empty? + lyr = OpenStudio::Model::SimpleGlazing.new(model, u0, shgc) + lyr.setName(a[:glazing][:id]) + else + lyr = lyr.get + end + + layers << lyr end c = OpenStudio::Model::Construction.new(layers) c.setName(id) # Adjust insulating layer thickness or conductivity to match requested Uo. - unless glazed - ro = 0 - ro = 1 / specs[:uo] - @@film[ specs[:type] ] if specs[:uo] - - if specs[:type] == :door # 1x layer, adjust conductivity - layer = c.getLayer(0).to_StandardOpaqueMaterial - return invalid("#{id} standard material?", mth, 0) if layer.empty? - - layer = layer.get - k = layer.thickness / ro - layer.setConductivity(k) - elsif ro > 0 # multiple layers, adjust insulating layer thickness - lyr = insulatingLayer(c) - return invalid("#{id} construction", mth, 0) if lyr[:index].nil? - return invalid("#{id} construction", mth, 0) if lyr[:type ].nil? - return invalid("#{id} construction", mth, 0) if lyr[:r ].zero? - - index = lyr[:index] - layer = c.getLayer(index).to_StandardOpaqueMaterial - return invalid("#{id} material @#{index}", mth, 0) if layer.empty? - - layer = layer.get - k = layer.conductivity - d = (ro - rsi(c) + lyr[:r]) * k - return invalid("#{id} adjusted m", mth, 0) if d < 0.03 - - nom = "OSut|" - nom += layer.nameString.gsub(/[^a-z]/i, "").gsub("OSut", "") - nom += "|" - nom += format("%03d", d*1000)[-3..-1] - layer.setName(nom) if model.getStandardOpaqueMaterialByName(nom).empty? - layer.setThickness(d) + if u and unglazed + ro = 1 / u - film + + if ro > 0 + if specs[:type] == :door # 1x layer, adjust conductivity + layer = c.getLayer(0).to_StandardOpaqueMaterial + return invalid("#{id} standard material?", mth, 0) if layer.empty? + + layer = layer.get + k = layer.thickness / ro + layer.setConductivity(k) + else # multiple layers, adjust insulating layer thickness + lyr = insulatingLayer(c) + return invalid("#{id} construction", mth, 0) if lyr[:index].nil? + return invalid("#{id} construction", mth, 0) if lyr[:type ].nil? + return invalid("#{id} construction", mth, 0) if lyr[:r ].zero? + + index = lyr[:index] + layer = c.getLayer(index).to_StandardOpaqueMaterial + return invalid("#{id} material @#{index}", mth, 0) if layer.empty? + + layer = layer.get + k = layer.conductivity + d = (ro - rsi(c) + lyr[:r]) * k + return invalid("#{id} adjusted m", mth, 0) if d < 0.03 + + nom = "OSut|" + nom += layer.nameString.gsub(/[^a-z]/i, "").gsub("OSut", "") + nom += "|" + nom += format("%03d", d*1000)[-3..-1] + + lyr = model.getStandardOpaqueMaterialByName(nom) + + if lyr.empty? + layer.setName(nom) + layer.setThickness(d) + else + omat = lyr.get + c.setLayer(index, omat) + end + end end end @@ -746,7 +757,7 @@ def genMass(sps = OpenStudio::Model::SpaceVector.new, ratio = 2.0) # Validates if a default construction set holds a base construction. # # @param set [OpenStudio::Model::DefaultConstructionSet] a default set - # @param bse [OpensStudio::Model::ConstructionBase] a construction base + # @param bse [OpenStudio::Model::ConstructionBase] a construction base # @param gr [Bool] if ground-facing surface # @param ex [Bool] if exterior-facing surface # @param tp [#to_s] a surface type @@ -1048,7 +1059,7 @@ def insulatingLayer(lc = nil) return invalid("lc", mth, 1, DBG, res) unless lc.respond_to?(NS) id = lc.nameString - return mismatch(id, lc, cl1, mth, DBG, res) unless lc.is_a?(cl) + return mismatch(id, lc, cl, mth, DBG, res) unless lc.is_a?(cl) lc.layers.each do |m| unless m.to_MasslessOpaqueMaterial.empty? @@ -1102,7 +1113,7 @@ def spandrel?(s = nil) id = s.nameString m1 = "#{id}:spandrel" m2 = "#{id}:spandrel:boolean" - return mismatch(id, s, cl, mth) unless s.is_a?(cl) + return mismatch(id, s, cl, mth, false) unless s.is_a?(cl) if s.additionalProperties.hasFeature("spandrel") val = s.additionalProperties.getFeatureAsBoolean("spandrel") @@ -1129,7 +1140,7 @@ def fenestration?(s = nil) return invalid("subsurface", mth, 1, DBG, false) unless s.respond_to?(NS) id = s.nameString - return mismatch(id, s, cl, mth, false) unless s.is_a?(cl) + return mismatch(id, s, cl, mth, DBG, false) unless s.is_a?(cl) # OpenStudio::Model::SubSurface.validSubSurfaceTypeValues # "FixedWindow" : fenestration @@ -1422,7 +1433,15 @@ def scheduleIntervalMinMax(sched = nil) id = sched.nameString return mismatch(id, sched, cl, mth, DBG, res) unless sched.is_a?(cl) - vals = sched.timeSeries.values + values = sched.timeSeries.values + + values.each do |value| + if value.respond_to?(:to_f) + vals << value.to_f + else + invalid("numerical at #{i}", mth, 1, ERR) + end + end res[:min] = vals.min.is_a?(Numeric) ? vals.min : nil res[:max] = vals.max.is_a?(Numeric) ? vals.min : nil @@ -1529,6 +1548,16 @@ def maxHeatScheduledSetpoint(zone = nil) res[:spt] = max if res[:spt] < max end end + + unless sched.to_ScheduleInterval.empty? + sched = sched.to_ScheduleInterval.get + max = scheduleIntervalMinMax(sched)[:max] + + if max + res[:spt] = max unless res[:spt] + res[:spt] = max if res[:spt] < max + end + end end return res if zone.thermostat.empty? @@ -1586,6 +1615,16 @@ def maxHeatScheduledSetpoint(zone = nil) end end + unless sched.to_ScheduleInterval.empty? + sched = sched.to_ScheduleInterval.get + max = scheduleIntervalMinMax(sched)[:max] + + if max + res[:spt] = max unless res[:spt] + res[:spt] = max if res[:spt] < max + end + end + unless sched.to_ScheduleYear.empty? sched = sched.to_ScheduleYear.get @@ -1707,6 +1746,16 @@ def minCoolScheduledSetpoint(zone = nil) res[:spt] = min if res[:spt] > min end end + + unless sched.to_ScheduleInterval.empty? + sched = sched.to_ScheduleInterval.get + min = scheduleIntervalMinMax(sched)[:min] + + if min + res[:spt] = min unless res[:spt] + res[:spt] = min if res[:spt] > min + end + end end return res if zone.thermostat.empty? @@ -1764,6 +1813,16 @@ def minCoolScheduledSetpoint(zone = nil) end end + unless sched.to_ScheduleInterval.empty? + sched = sched.to_ScheduleInerval.get + min = scheduleIntervalMinMax(sched)[:min] + + if min + res[:spt] = min unless res[:spt] + res[:spt] = min if res[:spt] > min + end + end + unless sched.to_ScheduleYear.empty? sched = sched.to_ScheduleYear.get @@ -2351,7 +2410,7 @@ def transforms(group = nil) # @return [OpenStudio::Vector3d] true normal vector # @return [nil] if invalid input (see logs) def trueNormal(s = nil, r = 0) - mth = "TBD::#{__callee__}" + mth = "OSut::#{__callee__}" cl = OpenStudio::Model::PlanarSurface return mismatch("surface", s, cl, mth) unless s.is_a?(cl) return invalid("rotation angle", mth, 2) unless r.respond_to?(:to_f) @@ -2384,31 +2443,31 @@ def scalar(v = OpenStudio::Vector3d.new, m = 0) ## # Returns OpenStudio 3D points as an OpenStudio point vector, validating - # points in the process (if Array). + # points in the process (e.g. if Array). # # @param pts [Set] OpenStudio 3D points # # @return [OpenStudio::Point3dVector] 3D vector (see logs if empty) def to_p3Dv(pts = nil) mth = "OSut::#{__callee__}" - cl1 = OpenStudio::Point3d - cl2 = OpenStudio::Point3dVector - cl3 = OpenStudio::Model::PlanarSurface - cl4 = Array v = OpenStudio::Point3dVector.new - if pts.is_a?(cl1) + if pts.is_a?(OpenStudio::Point3d) v << pts return v + elsif pts.is_a?(OpenStudio::Point3dVector) + return pts + elsif pts.is_a?(OpenStudio::Model::PlanarSurface) + pts.vertices.each { |pt| v << OpenStudio::Point3d.new(pt.x, pt.y, pt.z) } + return v end - return pts if pts.is_a?(cl2) - return pts.vertices if pts.is_a?(cl3) - - return mismatch("points", pts, cl1, mth, DBG, v) unless pts.is_a?(cl4) + return mismatch("points", pts, Array, mth, DBG, v) unless pts.is_a?(Array) pts.each do |pt| - return mismatch("point", pt, cl4, mth, DBG, v) unless pt.is_a?(cl1) + unless pt.is_a?(OpenStudio::Point3d) + return mismatch("point", pt, OpenStudio::Point3d, mth, DBG, v) + end end pts.each { |pt| v << OpenStudio::Point3d.new(pt.x, pt.y, pt.z) } @@ -2684,7 +2743,7 @@ def nextUp(pts = nil, pt = nil) pair = pts.each_cons(2).find { |p1, _| same?(p1, pt) } - pair.nil? ? pts.first : pair.last + pair.nil? ? pts[0] : pair[-1] end ## @@ -2775,21 +2834,25 @@ def verticalPlane(p1 = nil, p2 = nil) # @param pts [Set 0 - v = v[n..-1] if n < 0 + n = 0 if n.abs > v.size + v = v[0..n-1] if n > 0 + v = v[n..-1] if n < 0 v end @@ -2803,10 +2866,10 @@ def getUniques(pts = nil, n = 0) # @param pts [Set] 3D points # # @return [OpenStudio::Point3dVectorVector] line segments (see logs if empty) - def getSegments(pts = nil) + def segments(pts = nil) mth = "OSut::#{__callee__}" vv = OpenStudio::Point3dVectorVector.new - pts = getUniques(pts) + pts = uniques(pts) return vv if pts.size < 2 pts.each_with_index do |p1, i1| @@ -2833,7 +2896,6 @@ def getSegments(pts = nil) # @return [false] if invalid input (see logs) def segment?(pts = nil) pts = to_p3Dv(pts) - return false if pts.empty? return false unless pts.size == 2 return false if same?(pts[0], pts[1]) @@ -2850,10 +2912,10 @@ def segment?(pts = nil) # @param pts [OpenStudio::Point3dVector] 3D points # # @return [OpenStudio::Point3dVectorVector] triads (see logs if empty) - def getTriads(pts = nil, co = false) + def triads(pts = nil, co = false) mth = "OSut::#{__callee__}" vv = OpenStudio::Point3dVectorVector.new - pts = getUniques(pts) + pts = uniques(pts) return vv if pts.size < 2 pts.each_with_index do |p1, i1| @@ -2880,7 +2942,7 @@ def getTriads(pts = nil, co = false) # @param pts [Set] 3D points # # @return [Bool] whether set is a valid triad (i.e. a trio of 3D points) - # @return [false] if invalid input (see logs) + # @return [false] if invalid input (see 'to_p3Dv' logs) def triad?(pts = nil) pts = to_p3Dv(pts) return false if pts.empty? @@ -2905,18 +2967,17 @@ def pointAlongSegment?(p0 = nil, sg = []) mth = "OSut::#{__callee__}" cl1 = OpenStudio::Point3d cl2 = OpenStudio::Point3dVector - return mismatch( "point", p0, cl1, mth, DBG, false) unless p0.is_a?(cl1) - return mismatch("segment", sg, cl2, mth, DBG, false) unless segment?(sg) - + return mismatch("point", p0, cl1, mth, DBG, false) unless p0.is_a?(cl1) + return false unless segment?(sg) return true if holds?(sg, p0) - a = sg.first - b = sg.last + a = sg[ 0] + b = sg[-1] ab = b - a abn = b - a abn.normalize ap = p0 - a - sp = ap.dot(abn) + sp = ap.dot(abn) return false if sp < 0 apd = scalar(abn, sp) @@ -2941,9 +3002,9 @@ def pointAlongSegments?(p0 = nil, sgs = []) mth = "OSut::#{__callee__}" cl1 = OpenStudio::Point3d cl2 = OpenStudio::Point3dVectorVector - sgs = sgs.is_a?(cl2) ? sgs : getSegments(sgs) - return empty("segments", mth, DBG, false) if sgs.empty? - return mismatch("point", p0, cl, mth, DBG, false) unless p0.is_a?(cl1) + sgs = sgs.is_a?(cl2) ? sgs : segments(sgs) + return empty("segments", mth, DBG, false) if sgs.empty? + return mismatch("point", p0, cl1, mth, DBG, false) unless p0.is_a?(cl1) sgs.each { |sg| return true if pointAlongSegment?(p0, sg) } @@ -2958,9 +3019,9 @@ def pointAlongSegments?(p0 = nil, sgs = []) # # @return [OpenStudio::Point3d] point of intersection of both lines # @return [nil] if no intersection, equal, or invalid input (see logs) - def getLineIntersection(s1 = [], s2 = []) - s1 = getSegments(s1) - s2 = getSegments(s2) + def lineIntersection(s1 = [], s2 = []) + s1 = segments(s1) + s2 = segments(s2) return nil if s1.empty? return nil if s2.empty? @@ -2971,10 +3032,10 @@ def getLineIntersection(s1 = [], s2 = []) return nil if same?(s1, s2) return nil if same?(s1, s2.to_a.reverse) - a1 = s1[0] - a2 = s1[1] - b1 = s2[0] - b2 = s2[1] + a1 = s1.first + b1 = s2.first + a2 = s1.last + b2 = s2.last # Matching segment endpoints? return a1 if same?(a1, b1) @@ -2983,18 +3044,18 @@ def getLineIntersection(s1 = [], s2 = []) return a2 if same?(a2, b2) # Segment endpoint along opposite segment? - return a1 if pointAlongSegments?(a1, s2) - return a2 if pointAlongSegments?(a2, s2) - return b1 if pointAlongSegments?(b1, s1) - return b2 if pointAlongSegments?(b2, s1) + return a1 if pointAlongSegment?(a1, s2) + return a2 if pointAlongSegment?(a2, s2) + return b1 if pointAlongSegment?(b1, s1) + return b2 if pointAlongSegment?(b2, s1) - # Line segments as vectors. Skip if colinear. + # Line segments as vectors. Skip if collinear or parallel. a = a2 - a1 b = b2 - b1 xab = a.cross(b) return nil if xab.length.round(4) < TOL2 - # Link 1st point to other segment endpoints as vectors. Must be coplanar. + # Link 1st point to other segment endpoints, as vectors. Must be coplanar. a1b1 = b1 - a1 a1b2 = b2 - a1 xa1b1 = a.cross(a1b1) @@ -3035,7 +3096,7 @@ def getLineIntersection(s1 = [], s2 = []) return nil if a.dot(p0 - a1) < 0 # Ensure intersection is sandwiched between endpoints. - return nil unless pointAlongSegments?(p0, s2) && pointAlongSegments?(p0, s1) + return nil unless pointAlongSegment?(p0, s2) && pointAlongSegment?(p0, s1) p0 end @@ -3049,14 +3110,14 @@ def getLineIntersection(s1 = [], s2 = []) # @return [Bool] whether 3D line intersects 3D segments # @return [false] if invalid input (see logs) def lineIntersects?(l = [], s = []) - l = getSegments(l) - s = getSegments(s) + l = segments(l) + s = segments(s) return nil if l.empty? return nil if s.empty? l = l.first - s.each { |segment| return true if getLineIntersection(l, segment) } + s.each { |segment| return true if lineIntersection(l, segment) } false end @@ -3142,28 +3203,33 @@ def blc(pts = nil) end ## - # Returns sequential non-collinear points in an OpenStudio 3D point vector. + # Returns non-collinear points in an OpenStudio 3D point vector. # # @param pts [Set 0 - a = a[n-1..-1] if n < 0 + n = 0 if n.abs > a.size + a = a[0..n-1] if n > 0 + a = a[n..-1] if n < 0 to_p3Dv(a) end ## - # Returns sequential collinear points in an OpenStudio 3D point vector. + # Returns collinear points in an OpenStudio 3D point vector. # # @param pts [Set a.size + a = a[0..n-1] if n > 0 + a = a[n..-1] if n < 0 + + a end ## @@ -3237,7 +3314,7 @@ def poly(pts = nil, vx = false, uq = false, co = false, tt = false, sq = :no) # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # # Minimum 3 points? - p3 = getNonCollinears(pts, 3) + p3 = nonCollinears(pts, 3) return empty("polygon (non-collinears < 3)", mth, ERR, v) if p3.size < 3 # Coplanar? @@ -3268,8 +3345,8 @@ def poly(pts = nil, vx = false, uq = false, co = false, tt = false, sq = :no) # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # # Ensure uniqueness and/or non-collinearity. Preserve original sequence. p0 = a.first - a = getUniques(a).to_a if uq - a = getNonCollinears(a).to_a if co + a = uniques(a).to_a if uq + a = nonCollinears(a).to_a if co i0 = a.index { |pt| same?(pt, p0) } a = a.rotate(i0) unless i0.nil? @@ -3278,7 +3355,7 @@ def poly(pts = nil, vx = false, uq = false, co = false, tt = false, sq = :no) if vx && a.size > 3 zen = OpenStudio::Point3d.new(0, 0, 1000) - getTriads(a).each do |trio| + triads(a).each do |trio| p1 = trio[0] p2 = trio[1] p3 = trio[2] @@ -3344,31 +3421,31 @@ def pointWithinPolygon?(p0 = nil, s = [], entirely = false) return false unless pl.pointOnPlane(p0) entirely = false unless [true, false].include?(entirely) - segments = getSegments(s) + sgments = segments(s) # Along polygon edges, or near vertices? - if pointAlongSegments?(p0, segments) + if pointAlongSegments?(p0, sgments) return false if entirely return true unless entirely end - segments.each do |segment| + sgments.each do |sgment| # - draw vector from segment midpoint to point # - scale 1000x (assuming no building surface would be 1km wide) # - convert vector to an independent line segment # - loop through polygon segments, tally the number of intersections # - avoid double-counting polygon vertices as intersections # - return false if number of intersections is even - mid = midpoint(segment.first, segment.last) + mid = midpoint(sgment.first, sgment.last) mpV = scalar(mid - p0, 1000) p1 = p0 + mpV ctr = 0 # Skip if ~collinear. - next if mpV.cross(segment.last - segment.first).length.round(4) < TOL2 + next if mpV.cross(sgment.last - sgment.first).length.round(4) < TOL2 - segments.each do |sg| - intersect = getLineIntersection([p0, p1], sg) + sgments.each do |sg| + intersect = lineIntersection([p0, p1], sg) next unless intersect # Skip test altogether if one of the polygon vertices. @@ -3518,7 +3595,7 @@ def square?(pts = nil) return false if pts.empty? return false unless rectangular?(pts) - getSegments(pts).each do |pt| + segments(pts).each do |pt| l = (pt[1] - pt[0]).length d = l unless d return false unless l.round(2) == d.round(2) @@ -3552,7 +3629,7 @@ def fits?(p1 = nil, p2 = nil, entirely = false) 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| + segments(p1).each do |sg| mp = midpoint(sg.first, sg.last) return false unless pointWithinPolygon?(mp, p2) end @@ -3595,22 +3672,17 @@ def overlap(p1 = nil, p2 = nil, flat = false) cw1 = clockwise?(p01) a1 = cw1 ? p01.to_a.reverse : p01.to_a a2 = p02.to_a - a2 = flatten(a2).to_a if flat - return invalid("points 2", mth, 2, DBG, face) unless xyz?(a2, :z) - - cw2 = clockwise?(a2) - a2 = a2.reverse if cw2 else t = OpenStudio::Transformation.alignFace(p01) a1 = t.inverse * p01 a2 = t.inverse * p02 - a2 = flatten(a2).to_a if flat - return invalid("points 2", mth, 2, DBG, face) unless xyz?(a2, :z) - - cw2 = clockwise?(a2) - a2 = a2.reverse if cw2 end + a2 = flatten(a2).to_a if flat + cw2 = clockwise?(a2) + a2 = a2.reverse if cw2 + return invalid("points 2", mth, 2, DBG, face) unless xyz?(a2, :z) + # Return either (transformed) polygon if one fits into the other. p1t = p01 @@ -3690,7 +3762,7 @@ def cast(p1 = nil, p2 = nil, ray = nil) p2 = poly(p2) return face if p1.empty? return face if p2.empty? - return mismatch("ray", ray, cl, mth) unless ray.is_a?(cl) + return mismatch("ray", ray, cl, mth, face) unless ray.is_a?(cl) # From OpenStudio SDK v3.7.0 onwards, one could/should rely on: # @@ -4023,12 +4095,12 @@ def outline(a = [], bfr = 0, flat = true) # # @param [Set] a triad (3D points) # - # @return [Set] a rectangular ULC box (see logs if empty) + # @return [Set] a rectangular BLC box (see logs if empty) def triadBox(pts = nil) mth = "OSut::#{__callee__}" bkp = OpenStudio::Point3dVector.new box = [] - pts = getNonCollinears(pts) + pts = nonCollinears(pts) return bkp if pts.empty? t = xyz?(pts, :z) ? nil : OpenStudio::Transformation.alignFace(pts) @@ -4065,7 +4137,7 @@ def triadBox(pts = nil) 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) + box = nonCollinears(box, 4) return bkp unless box.size == 4 box = blc(box) @@ -4098,7 +4170,7 @@ def medialBox(pts = nil) # Generate vertical plane along longest segment. mpoints = [] - sgs = getSegments(pts) + sgs = segments(pts) longest = sgs.max_by { |s| OpenStudio.getDistanceSquared(s.first, s.last) } plane = verticalPlane(longest.first, longest.last) @@ -4112,7 +4184,7 @@ def medialBox(pts = nil) box << mpoints.first box << mpoints.last box << plane.project(mpoints.last) - box = getNonCollinears(box).to_a + box = nonCollinears(box).to_a return bkp unless box.size == 4 box = clockwise?(box) ? blc(box.reverse) : blc(box) @@ -4165,16 +4237,16 @@ def boundedBox(pts = nil) aire = 0 # PATH C : Right-angle, midpoint triad approach. - getSegments(pts).each do |sg| + segments(pts).each do |sg| m0 = midpoint(sg.first, sg.last) - getSegments(pts).each do |seg| + segments(pts).each do |seg| p1 = seg.first p2 = seg.last next if same?(p1, sg.first) next if same?(p1, sg.last) next if same?(p2, sg.first) - next if same?(p2, sg.first) + next if same?(p2, sg.last) out = triadBox(OpenStudio::Point3dVector.new([m0, p1, p2])) next if out.empty? @@ -4194,7 +4266,7 @@ def boundedBox(pts = nil) end # PATH D : Right-angle triad approach, may override PATH C boxes. - getSegments(pts).each do |sg| + segments(pts).each do |sg| p0 = sg.first p1 = sg.last @@ -4227,7 +4299,7 @@ def boundedBox(pts = nil) # PATH E : Medial box, segment approach. aire = 0 - getSegments(pts).each do |sg| + segments(pts).each do |sg| p0 = sg.first p1 = sg.last @@ -4260,7 +4332,7 @@ def boundedBox(pts = nil) # PATH F : Medial box, triad approach. aire = 0 - getTriads(pts).each do |sg| + triads(pts).each do |sg| p0 = sg[0] p1 = sg[1] p2 = sg[2] @@ -4292,7 +4364,7 @@ def boundedBox(pts = nil) holes = OpenStudio::Point3dVectorVector.new OpenStudio.computeTriangulation(outer, holes).each do |triangle| - getSegments(triangle).each do |sg| + segments(triangle).each do |sg| p0 = sg.first p1 = sg.last @@ -4349,7 +4421,7 @@ def boundedBox(pts = nil) # # @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, force = false) + def realignedFace(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) @@ -4372,11 +4444,11 @@ def getRealignedFace(pts = nil, force = false) box = boundedBox(pts) return invalid("bounded box", mth, 0, DBG, out) if box.empty? - segments = getSegments(box) - return invalid("bounded box segments", mth, 0, DBG, out) if segments.empty? + sgments = segments(box) + return invalid("bounded box segments", mth, 0, DBG, out) if sgments.empty? # Deterministic ID of box rotation/translation 'origin'. - segments.each_with_index do |sg, idx| + sgments.each_with_index do |sg, idx| sgs[sg] = {} sgs[sg][:idx] = idx sgs[sg][:mid] = midpoint(sg[0], sg[1]) @@ -4396,10 +4468,10 @@ def getRealignedFace(pts = nil, force = false) i = sg0[:idx] end - k = i + 2 < segments.size ? i + 2 : i - 2 + k = i + 2 < sgments.size ? i + 2 : i - 2 - origin = midpoint(segments[i][0], segments[i][1]) - terminal = midpoint(segments[k][0], segments[k][1]) + origin = midpoint(sgments[i][0], sgments[i][1]) + terminal = midpoint(sgments[k][0], sgments[k][1]) seg = terminal - origin right = OpenStudio::Point3d.new(origin.x + d, origin.y , 0) - origin north = OpenStudio::Point3d.new(origin.x, origin.y + d, 0) - origin @@ -4441,7 +4513,7 @@ def getRealignedFace(pts = nil, force = false) # @param pts [Set] 3D points, once re/aligned # @param force [Bool] whether to force rotation of (narrow) bounded box # - # @return [Float] width al©ong X-axis, once re/aligned + # @return [Float] width along X-axis, once re/aligned # @return [0.0] if invalid inputs def alignedWidth(pts = nil, force = false) mth = "OSut::#{__callee__}" @@ -4453,7 +4525,7 @@ def alignedWidth(pts = nil, force = false) force = false end - pts = getRealignedFace(pts, force)[:set] + pts = realignedFace(pts, force)[:set] return 0 if pts.size < 2 pts.max_by(&:x).x - pts.min_by(&:x).x @@ -4477,12 +4549,84 @@ def alignedHeight(pts = nil, force = false) force = false end - pts = getRealignedFace(pts, force)[:set] + pts = realignedFace(pts, force)[:set] return 0 if pts.size < 2 pts.max_by(&:y).y - pts.min_by(&:y).y end + ## + # Fetch a space's full height (in space coordinates). The solution considers + # all surface types ("Floor" vs "Wall" vs "RoofCeiling"). + # + # @param space [OpenStudio::Model::Space] a space + # + # @return [Float] full height of space (0 if invalid input) + def spaceHeight(space = nil) + return 0 unless space.is_a?(OpenStudio::Model::Space) + + minZ = 10000 + maxZ = -10000 + + space.surfaces.each do |surface| + minZ = [surface.vertices.min_by(&:z).z, minZ].min + maxZ = [surface.vertices.max_by(&:z).z, maxZ].max + end + + maxZ < minZ ? 0 : maxZ - minZ + end + + ## + # Fetch a space's width, based on the geometry of space floors. + # + # @param space [OpenStudio::Model::Space] a space + # + # @return [Float] width of a space (0 if invalid input) + def spaceWidth(space = nil) + return 0 unless space.is_a?(OpenStudio::Model::Space) + + floors = facets(space, "all", "Floor") + return 0 if floors.empty? + + # Automatically determining a space's "width" is not straightforward: + # - a space may hold multiple floor surfaces at various Z-axis levels + # - a space may hold multiple floor surfaces, with unique "widths" + # - a floor surface may expand/contract (in "width") along its length. + # + # First, attempt to merge all floor surfaces together as 1x polygon: + # - select largest floor surface (in area) + # - determine its 3D plane + # - retain only other floor surfaces sharing same 3D plane + # - recover potential union between floor surfaces + # - fall back to largest floor surface if invalid union + # - return width of largest bounded box + floors = floors.sort_by(&:grossArea).reverse + floor = floors.first + plane = floor.plane + t = OpenStudio::Transformation.alignFace(floor.vertices) + polyg = poly(floor, false, true, true, t, :ulc).to_a.reverse + return 0 if polyg.empty? + + if floors.size > 1 + floors = floors.select { |flr| plane.equal(flr.plane, 0.001) } + + if floors.size > 1 + polygs = floors.map { |flr| poly(flr, false, true, true, t, :ulc) } + polygs = polygs.reject { |plg| plg.empty? } + polygs = polygs.map { |plg| plg.to_a.reverse } + union = OpenStudio.joinAll(polygs, 0.01).first + polyg = poly(union, false, true, true) + return 0 if polyg.empty? + end + end + + res = realignedFace(polyg.to_a.reverse) + return 0 if res[:box].nil? + + # A bounded box's 'height', at its narrowest, is its 'width'. + height(res[:box]) + end + ## # 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 @@ -4576,7 +4720,7 @@ def genAnchors(s = nil, set = [], tag = :box) else st[:t] = OpenStudio::Transformation.alignFace(pts) unless st.key?(:t) tpts = st[:t].inverse * st[tag] - o = getRealignedFace(tpts, true) + o = realignedFace(tpts, true) tpts = st[:t] * (o[:r] * (o[:t] * o[:set])) st[:out] = o @@ -4594,7 +4738,7 @@ def genAnchors(s = nil, set = [], tag = :box) nb = 0 # Check for intersections between leader line and larger polygon edges. - getSegments(pts).each do |sg| + segments(pts).each do |sg| break unless nb.zero? next if holds?(sg, pt) @@ -4608,7 +4752,7 @@ def genAnchors(s = nil, set = [], tag = :box) ost = other[tag] - getSegments(ost).each { |sg| nb += 1 if lineIntersects?(ld, sg) } + segments(ost).each { |sg| nb += 1 if lineIntersects?(ld, sg) } end # ... and previous leader lines (first come, first serve basis). @@ -4626,7 +4770,7 @@ def genAnchors(s = nil, set = [], tag = :box) end # Finally, check for self-intersections. - getSegments(tpts).each do |sg| + segments(tpts).each do |sg| break unless nb.zero? next if holds?(sg, tpts.first) @@ -4686,8 +4830,9 @@ def genExtendedVertices(s = nil, set = [], tag = :vtx) set.each_with_index do |st, i| 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?) + next if st.key?(:void) && st[:void] + 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) @@ -4696,9 +4841,9 @@ def genExtendedVertices(s = nil, set = [], tag = :vtx) return invalid("#{str2} polygon", mth, 0, DBG, a) if stt.empty? ld = st[:ld] - return mismatch(str, ld, Hash, mth, DBG, a) unless ld.is_a?(Hash) - return hashkey( str, ld, s, mth, DBG, a) unless ld.key?(s) - return mismatch(str, ld[s], cl, mth, DBG, a) unless ld[s].is_a?(cl) + return mismatch(str2, ld, Hash, mth, DBG, a) unless ld.is_a?(Hash) + 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) end # Re-sequence polygon vertices. @@ -5091,7 +5236,7 @@ def genSlab(pltz = [], z = 0) pltz.each_with_index do |plt, i| id = "plate # #{i+1} (index #{i})" - return mismatch(id, plt, cl1, mth, DBG, slb) unless plt.is_a?(cl2) + return mismatch(id, plt, cl2, mth, DBG, slb) unless plt.is_a?(cl2) return hashkey( id, plt, :x, mth, DBG, slb) unless plt.key?(:x ) return hashkey( id, plt, :y, mth, DBG, slb) unless plt.key?(:y ) return hashkey( id, plt, :dx, mth, DBG, slb) unless plt.key?(:dx) @@ -5142,7 +5287,7 @@ def genSlab(pltz = [], z = 0) end # Once joined, re-adjust Z-axis coordinates. - unless z.zero? + unless z.round(2) == 0.00 vtx = OpenStudio::Point3dVector.new slb.each { |pt| vtx << OpenStudio::Point3d.new(pt.x, pt.y, z) } slb = vtx @@ -5162,18 +5307,18 @@ def genSlab(pltz = [], z = 0) # @param spaces [Set] target spaces # # @return [Array] roofs (may be empty) - def getRoofs(spaces = []) + def roofs(spaces = []) mth = "OSut::#{__callee__}" up = OpenStudio::Point3d.new(0,0,1) - OpenStudio::Point3d.new(0,0,0) - roofs = [] + 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) } # Space-specific outdoor-facing roof surfaces. - roofs = facets(spaces, "Outdoors", "RoofCeiling") - roofs = roofs.select { |roof| roof?(roof) } + rfs = facets(spaces, "Outdoors", "RoofCeiling") + rfs = rfs.select { |rf| roof?(rf) } spaces.each do |space| # When unoccupied spaces are involved (e.g. plenums, attics), the target @@ -5209,12 +5354,12 @@ def getRoofs(spaces = []) cst = cast(cv0, rvi, up) next unless overlaps?(cst, rvi, false) - roofs << ruf unless roofs.include?(ruf) + rfs << ruf unless rfs.include?(ruf) end end end - roofs + rfs end ## @@ -5240,10 +5385,10 @@ def daylit?(space = nil, sidelit = true, toplit = true, baselit = true) return invalid("baselit" , mth, 4, DBG, false) unless ck4 walls = sidelit ? facets(space, "Outdoors", "Wall") : [] - roofs = toplit ? facets(space, "Outdoors", "RoofCeiling") : [] + rufs = toplit ? facets(space, "Outdoors", "RoofCeiling") : [] floors = baselit ? facets(space, "Outdoors", "Floor") : [] - (walls + roofs + floors).each do |surface| + (walls + rufs + floors).each do |surface| surface.subSurfaces.each do |sub| # All fenestrated subsurface types are considered, as user can set these # explicitly (e.g. skylight in a wall) in OpenStudio. @@ -5370,11 +5515,11 @@ def addSubs(s = nil, subs = [], clear = false, bound = false, realign = false, b box = boundedBox(s0) if realign - s00 = getRealignedFace(box, true) + s00 = realignedFace(box, true) return invalid("bound realignment", mth, 0, DBG, false) unless s00[:set] end elsif realign - s00 = getRealignedFace(s0, false) + s00 = realignedFace(s0, false) return invalid("unbound realignment", mth, 0, DBG, false) unless s00[:set] end @@ -5983,7 +6128,6 @@ def grossRoofArea(spaces = []) # previously-added leader lines. # # @todo: revise approach for attics ONCE skylight wells have been added. - olap = nil olap = overlap(cst, rvi, false) next if olap.empty? @@ -6013,24 +6157,24 @@ def grossRoofArea(spaces = []) # (Array of 2x linked surfaces). Each surface may be linked to more than one # horizontal ridge. # - # @param roofs [Array] target surfaces + # @param rfs [Array] target surfaces # # @return [Array] horizontal ridges (see logs if empty) - def getHorizontalRidges(roofs = []) + def horizontalRidges(rfs = []) mth = "OSut::#{__callee__}" ridges = [] - return ridges unless roofs.is_a?(Array) + return ridges unless rfs.is_a?(Array) - roofs = roofs.select { |s| s.is_a?(OpenStudio::Model::Surface) } - roofs = roofs.select { |s| sloped?(s) } + rfs = rfs.select { |s| s.is_a?(OpenStudio::Model::Surface) } + rfs = rfs.select { |s| sloped?(s) } - roofs.each do |roof| - maxZ = roof.vertices.max_by(&:z).z - next if roof.space.empty? + rfs.each do |rf| + maxZ = rf.vertices.max_by(&:z).z + next if rf.space.empty? - space = roof.space.get + space = rf.space.get - getSegments(roof).each do |edge| + segments(rf).each do |edge| next unless xyz?(edge, :z, maxZ) # Skip if already tracked. @@ -6045,18 +6189,18 @@ def getHorizontalRidges(roofs = []) next if match - ridge = { edge: edge, length: (edge[1] - edge[0]).length, roofs: [roof] } + ridge = { edge: edge, length: (edge[1] - edge[0]).length, roofs: [rf] } # Links another roof (same space)? match = false - roofs.each do |ruf| + rfs.each do |ruf| break if match - next if ruf == roof + next if ruf == rf next if ruf.space.empty? next unless ruf.space.get == space - getSegments(ruf).each do |edg| + segments(ruf).each do |edg| break if match next unless same?(edge, edg) || same?(edge, edg.reverse) @@ -6100,7 +6244,7 @@ def toToplit(spaces = [], opts = {}) 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 + return invalid("size", mth, 0, ERR, []) if w.round(2) < gap4 else return mismatch("size", opts[:size], Numeric, mth, DBG, []) end @@ -6123,12 +6267,12 @@ def toToplit(spaces = [], opts = {}) 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| roofs(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? + return empty("spaces", mth, WRN, []) if spaces.empty? else - return mismatch("spaces", spaces, Array, mth, DBG, 0) + return mismatch("spaces", spaces, Array, mth, DBG, []) end # Unfenestrated spaces have no windows, glazed doors or skylights. By @@ -6169,7 +6313,7 @@ def toToplit(spaces = [], opts = {}) # Gather roof surfaces - possibly those of attics or plenums above. spaces.each do |sp| - getRoofs(sp).each do |rf| + roofs(sp).each do |rf| espaces[sp] = {roofs: []} unless espaces.key?(sp) espaces[sp][:roofs] << rf unless espaces[sp][:roofs].include?(rf) end @@ -6244,6 +6388,7 @@ def addSkyLights(spaces = [], opts = {}) bfr = 0.005 # minimum array perimeter buffer (no wells) w = 1.22 # default 48" x 48" skylight base w2 = w * w # m2 + v = OpenStudio.openStudioVersion.split(".").join.to_i # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # # Excerpts of ASHRAE 90.1 2022 definitions: @@ -6360,10 +6505,10 @@ def addSkyLights(spaces = [], opts = {}) if frame.respond_to?(:frameWidth) frame = nil if v < 321 - frame = nil if f.frameWidth.round(2) < 0 - frame = nil if f.frameWidth.round(2) > gap + frame = nil if frame.frameWidth.round(2) < 0 + frame = nil if frame.frameWidth.round(2) > gap - f = f.frameWidth if frame + f = frame.frameWidth if frame log(WRN, "Skip Frame&Divider (#{mth})") unless frame else frame = nil @@ -6420,7 +6565,7 @@ def addSkyLights(spaces = [], opts = {}) end # Purge if requested. - getRoofs(spaces).each { |s| s.subSurfaces.map(&:remove) } if clear + roofs(spaces).each { |s| s.subSurfaces.map(&:remove) } if clear # Safely exit, e.g. if strictly called to purge existing roof subsurfaces. return 0 if area && area.round(2) == 0 @@ -6588,14 +6733,14 @@ def addSkyLights(spaces = [], opts = {}) next unless opts[opt] == false case opt - when :sidelit then filters.map! { |f| f.include?("b") ? f.delete("b") : f } - when :sloped then filters.map! { |f| f.include?("c") ? f.delete("c") : f } - when :plenum then filters.map! { |f| f.include?("d") ? f.delete("d") : f } - when :attic then filters.map! { |f| f.include?("e") ? f.delete("e") : f } + when :sidelit then filters.map! { |fl| fl.include?("b") ? fl.delete("b") : fl } + when :sloped then filters.map! { |fl| fl.include?("c") ? fl.delete("c") : fl } + when :plenum then filters.map! { |fl| fl.include?("d") ? fl.delete("d") : fl } + when :attic then filters.map! { |fl| fl.include?("e") ? fl.delete("e") : fl } end end - filters.reject! { |f| f.empty? } + filters.reject! { |fl| fl.empty? } filters.uniq! # Remaining filters may be further pruned automatically after space/roof @@ -6698,7 +6843,7 @@ def addSkyLights(spaces = [], opts = {}) # Process outdoor-facing roof surfaces of plenums and attics above. rooms.each do |space, room| t0 = room[:t0] - rufs = getRoofs(space) - room[:roofs] + rufs = roofs(space) - room[:roofs] rufs.each do |ruf| next unless roof?(ruf) @@ -6860,12 +7005,12 @@ def addSkyLights(spaces = [], opts = {}) # Ensure uniqueness of plenum roofs. attics.values.each do |attic| attic[:roofs ].uniq! - attic[:ridges] = getHorizontalRidges(attic[:roofs]) # @todo + attic[:ridges] = horizontalRidges(attic[:roofs]) # @todo end plenums.values.each do |plenum| plenum[:roofs ].uniq! - plenum[:ridges] = getHorizontalRidges(plenum[:roofs]) # @todo + plenum[:ridges] = horizontalRidges(plenum[:roofs]) # @todo end # Regardless of the selected skylight arrangement pattern, the solution only @@ -7388,7 +7533,7 @@ def addSkyLights(spaces = [], opts = {}) pattern = "array" elsif fpm2.keys.include?("strips") pattern = "strips" - else fpm2.keys.include?("strip") + else # fpm2.keys.include?("strip") pattern = "strip" end else @@ -7399,7 +7544,7 @@ def addSkyLights(spaces = [], opts = {}) pattern = "strip" elsif fpm2.keys.include?("strips") pattern = "strips" - else fpm2.keys.include?("array") + else # fpm2.keys.include?("array") pattern = "array" end end @@ -7502,7 +7647,7 @@ def addSkyLights(spaces = [], opts = {}) # Size contraction: round 2: prioritize larger sets. adm2 = 0 - sets.each_with_index do |set, i| + sets.each_with_index do |set| next if set[:w].round(2) <= w0 next if set[:d].round(2) <= w0 @@ -7674,12 +7819,12 @@ def addSkyLights(spaces = [], opts = {}) # Generate well walls. vX = cast(roof, tile, ray) - s0 = getSegments(t0 * roof.vertices) - sX = getSegments(t0 * vX) + s0 = segments(t0 * roof.vertices) + sX = segments(t0 * vX) s0.each_with_index do |sg, j| - sg0 = sg.to_a - sgX = sX[j].to_a + sg0 = sg + sgX = sX[j] vec = OpenStudio::Point3dVector.new vec << sg0.first vec << sg0.last diff --git a/lib/osut/version.rb b/lib/osut/version.rb index 68bb03c..7b75bd4 100644 --- a/lib/osut/version.rb +++ b/lib/osut/version.rb @@ -1,6 +1,6 @@ # BSD 3-Clause License # -# Copyright (c) 2022-2024, Denis Bourgeois +# Copyright (c) 2022-2025, Denis Bourgeois # All rights reserved. # # Redistribution and use in source and binary forms, with or without @@ -29,5 +29,5 @@ # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. module OSut - VERSION = "0.6.0".freeze # OSut version + VERSION = "0.7.0".freeze end diff --git a/osut.gemspec b/osut.gemspec index d4799aa..2db34fa 100644 --- a/osut.gemspec +++ b/osut.gemspec @@ -26,7 +26,7 @@ Gem::Specification.new do |s| s.required_ruby_version = [">= 2.5.0", "< 4"] s.metadata = {} - s.add_dependency "oslg", ">= 0.3.0" + s.add_dependency "oslg", ">= 0.4.0" s.add_development_dependency "bundler", "~> 2.1" s.add_development_dependency "rake", "~> 13.0" s.add_development_dependency "rspec", "~> 3.11" diff --git a/spec/osut_tests_spec.rb b/spec/osut_tests_spec.rb index 5ae387a..b8cf6e9 100644 --- a/spec/osut_tests_spec.rb +++ b/spec/osut_tests_spec.rb @@ -164,7 +164,7 @@ expect(surface.layers[1].nameString).to eq("OSut|polyiso|108") expect(surface.layers[2].nameString).to eq("OSut|concrete|100") - # Insulated, parking garage roof (polyiso above 8" slab). + # Roof above conditioned parking garage (polyiso under 8" slab). specs = {type: :roof, uo: uo2, clad: :heavy, frame: :medium, finish: :none} surface = cls1.genConstruction(model, specs) expect(surface).to_not be_nil @@ -291,6 +291,22 @@ u = 1 / cls1::rsi(surface) expect(u).to be_within(TOL).of(uo9) + # Invalid Uo (here, skylights and windows inherit default Uo values) + specs = {type: :skylight, uo: nil} + surface = cls1::genConstruction(model, specs) + expect(surface).to be_a(OpenStudio::Model::LayeredConstruction) + expect(surface.layers.size).to eq(1) + u = 1 / cls1::rsi(surface) + expect(u).to be_within(TOL).of(uo[:skylight]) + + # Invalid Uo (here, Uo-adjustments are ignored altogether) + specs = {type: :wall, uo: nil} + surface = cls1::genConstruction(model, specs) + expect(surface).to be_a(OpenStudio::Model::LayeredConstruction) + expect(surface.layers.size).to eq(4) + u = 1 / cls1::rsi(surface) + expect(u).to be_within(TOL).of(2.23) # not matching any defaults + expect(cls1.status.zero?).to be true end @@ -790,8 +806,12 @@ expect(model).to_not be_empty model = model.get - m = "OSut::insulatingLayer" - m1 = "Invalid 'lc' arg #1 (#{m})" + m = "OSut::insulatingLayer" + cl1 = OpenStudio::Model::Surface + cl2 = OpenStudio::Model::LayeredConstruction + n1 = "Entryway Wall 1" + m1 = "Invalid 'lc' arg #1 (#{m})" + m2 = "'#{n1}' #{cl1}? expecting #{cl2} (#{m})" model.getLayeredConstructions.each do |lc| lyr = mod1.insulatingLayer(lc) @@ -838,7 +858,6 @@ end lyr = mod1.insulatingLayer(nil) - expect(mod1.debug?).to be true expect(lyr[:index]).to be_nil expect(lyr[:type ]).to be_nil expect(lyr[:r ]).to be_zero @@ -848,7 +867,6 @@ expect(mod1.clean!).to eq(DBG) lyr = mod1.insulatingLayer("") - expect(mod1.debug?).to be true expect(lyr[:index]).to be_nil expect(lyr[:type ]).to be_nil expect(lyr[:r ]).to be_zero @@ -858,13 +876,25 @@ expect(mod1.clean!).to eq(DBG) lyr = mod1.insulatingLayer(model) - expect(mod1.debug?).to be true expect(lyr[:index]).to be_nil expect(lyr[:type ]).to be_nil expect(lyr[:r ]).to be_zero expect(mod1.debug?).to be true expect(mod1.logs.size).to eq(1) expect(mod1.logs.first[:message]).to eq(m1) + + eWall1 = model.getSurfaceByName(n1) + expect(eWall1).to_not be_empty + eWall1 = eWall1.get + + expect(mod1.clean!).to eq(DBG) + lyr = mod1.insulatingLayer(eWall1) + expect(lyr[:index]).to be_nil + expect(lyr[:type ]).to be_nil + expect(lyr[:r ]).to be_zero + expect(mod1.debug?).to be true + expect(mod1.logs.size).to eq(1) + expect(mod1.logs.first[:message]).to eq(m2) end it "checks for spandrels" do @@ -952,7 +982,7 @@ expect(minmax).to have_key(:min) expect(minmax).to have_key(:max) expect(minmax[:min]).to be_within(TOL).of(23.89) - expect(minmax[:min]).to be_within(TOL).of(23.89) + expect(minmax[:max]).to be_within(TOL).of(23.89) expect(cls1.status).to be_zero expect(cls1.logs).to be_empty @@ -1259,9 +1289,27 @@ module M expect(cc.setTemperatureCalculationRequestedAfterLayerNumber(1)).to be true expect(floor.setConstruction(cc)).to be true + # Test 'fixed interval' schedule. Annual time series - no variation. + start = model.getYearDescription.makeDate(1, 1) + inter = OpenStudio::Time.new(0, 1, 0, 0) + values = OpenStudio.createVector(Array.new(8760, 22.78)) + series = OpenStudio::TimeSeries.new(start, inter, values, "") + limits = OpenStudio::Model::ScheduleTypeLimits.new(model) + limits.setName("Radiant Electric Heating Setpoint Schedule Type Limits") + expect(limits.setNumericType("Continuous")).to be true + expect(limits.setUnitType("Temperature")).to be true + + schedule = OpenStudio::Model::ScheduleFixedInterval.new(model) + schedule.setName("Radiant Electric Heating Setpoint Schedule") + expect(schedule.setTimeSeries(series)).to be true + expect(schedule.setTranslatetoScheduleFile(false)).to be true + expect(schedule.setScheduleTypeLimits(limits)).to be true + + tvals = schedule.timeSeries.values + expect(tvals).to be_a(OpenStudio::Vector) + tvals.each { |tval| expect(tval).to be_a(Numeric) } + availability = M.availabilitySchedule(model) - schedule = OpenStudio::Model::ScheduleConstant.new(model) - expect(schedule.setValue(22.78)).to be true # reuse cooling setpoint # Create radiant electric heating. ht = OpenStudio::Model::ZoneHVACLowTemperatureRadiantElectric.new( @@ -1866,7 +1914,7 @@ module M pts = r * a expect(mod1.same?(pts, vtx)).to be true - output1 = mod1.getRealignedFace(vtx) + output1 = mod1.realignedFace(vtx) expect(mod1.status).to be_zero expect(output1).to be_a Hash expect(output1).to have_key(:set) @@ -1880,7 +1928,7 @@ module M ubbox1 = output1[:bbox] # Realign a previously realigned surface? - output2 = mod1.getRealignedFace(output1[:box]) + output2 = mod1.realignedFace(output1[:box]) ubox2 = output1[ :box] ubbox2 = output1[:bbox] @@ -1909,12 +1957,12 @@ module M vtx << OpenStudio::Point3d.new( 5, 2, 0) vtx << OpenStudio::Point3d.new( 6, 4, 0) - output3 = mod1.getRealignedFace(vtx) + output3 = mod1.realignedFace(vtx) ubox3 = output3[ :box] ubbox3 = output3[:bbox] # Realign a previously realigned surface? - output4 = mod1.getRealignedFace(output3[:box]) + output4 = mod1.realignedFace(output3[:box]) ubox4 = output4[ :box] ubbox4 = output4[:bbox] @@ -1945,12 +1993,12 @@ module M vtx << OpenStudio::Point3d.new( 1, 4, 0) vtx << OpenStudio::Point3d.new( 2, 2, 0) - output5 = mod1.getRealignedFace(vtx) + output5 = mod1.realignedFace(vtx) ubox5 = output5[ :box] ubbox5 = output5[:bbox] # Realign a previously realigned surface? - output6 = mod1.getRealignedFace(output5[:box]) + output6 = mod1.realignedFace(output5[:box]) ubox6 = output6[ :box] ubbox6 = output6[:bbox] @@ -1985,12 +2033,12 @@ module M vtx << OpenStudio::Point3d.new( 2, 6, 0) vtx << OpenStudio::Point3d.new( 1, 4, 0) - output7 = mod1.getRealignedFace(vtx) + output7 = mod1.realignedFace(vtx) ubox7 = output7[ :box] ubbox7 = output7[:bbox] # Realign a previously realigned surface? - output8 = mod1.getRealignedFace(ubox7) + output8 = mod1.realignedFace(ubox7) ubox8 = output8[ :box] ubbox8 = output8[:bbox] @@ -2025,11 +2073,11 @@ module M vtx << OpenStudio::Point3d.new( 6, 2, 0) vtx << OpenStudio::Point3d.new( 6, 4, 0) - output9 = mod1.getRealignedFace(vtx) + output9 = mod1.realignedFace(vtx) ubox9 = output9[ :box] ubbox9 = output9[:bbox] - output10 = mod1.getRealignedFace(vtx, true) # no impact + output10 = mod1.realignedFace(vtx, true) # no impact ubox10 = output10[ :box] ubbox10 = output10[:bbox] expect(mod1.same?(ubox9, ubox10)).to be true @@ -2042,11 +2090,11 @@ module M vtx << OpenStudio::Point3d.new( 4, 2, 0) vtx << OpenStudio::Point3d.new( 4, 6, 0) - output11 = mod1.getRealignedFace(vtx) + output11 = mod1.realignedFace(vtx) ubox11 = output11[ :box] ubbox11 = output11[:bbox] - output12 = mod1.getRealignedFace(vtx, true) # narrow, now wide + output12 = mod1.realignedFace(vtx, true) # narrow, now wide ubox12 = output12[ :box] ubbox12 = output12[:bbox] expect(mod1.same?(ubox11, ubox12)).to be false @@ -2062,14 +2110,14 @@ module M vtx << OpenStudio::Point3d.new( 3, 8, 0) vtx << OpenStudio::Point3d.new( 1, 4, 0) - output13 = mod1.getRealignedFace(vtx) + output13 = mod1.realignedFace(vtx) uset13 = output13[ :set] ubox13 = output13[ :box] ubbox13 = output13[:bbox] # Pre-isolate bounded box (preferable with irregular surfaces). box = mod1.boundedBox(vtx) - output14 = mod1.getRealignedFace(box) + output14 = mod1.realignedFace(box) uset14 = output14[ :set] ubox14 = output14[ :box] ubbox14 = output14[:bbox] @@ -2178,20 +2226,20 @@ module M sg2 << OpenStudio::Point3d.new(12, 14, 0) sg2 << OpenStudio::Point3d.new(12, 6, 0) - expect(mod1.getLineIntersection(sg1, sg2)).to be_nil + expect(mod1.lineIntersection(sg1, sg2)).to be_nil sg1 = OpenStudio::Point3dVector.new sg1 << OpenStudio::Point3d.new(0.60,19.06, 0) sg1 << OpenStudio::Point3d.new(0.60, 0.60, 0) sg1 << OpenStudio::Point3d.new(0.00, 0.00, 0) sg1 << OpenStudio::Point3d.new(0.00,19.66, 0) - sgs1 = mod1.getSegments(sg1) + sgs1 = mod1.segments(sg1) sg2 = OpenStudio::Point3dVector.new sg2 << OpenStudio::Point3d.new(9.83, 9.83, 0) sg2 << OpenStudio::Point3d.new(0.00, 0.00, 0) sg2 << OpenStudio::Point3d.new(0.00,19.66, 0) - sgs2 = mod1.getSegments(sg2) + sgs2 = mod1.segments(sg2) expect(mod1.same?(sg1[2], sg2[1])).to be true expect(mod1.same?(sg1[3], sg2[2])).to be true @@ -2306,10 +2354,10 @@ module M glazing = OpenStudio::Model::SubSurface.new(vec, model) # Glazing fits?, overlaps? parallel? - expect(mod1.parallel?(glazing, wall)).to be(true) + expect(mod1.parallel?(glazing, wall)).to be true expect(mod1.fits?(glazing, wall)).to be true expect(mod1.overlaps?(glazing, wall)).to be true - expect(mod1.parallel?(wall, glazing)).to be(true) + expect(mod1.parallel?(wall, glazing)).to be true expect(mod1.fits?(wall, glazing)).to be true expect(mod1.overlaps?(wall, glazing)).to be true @@ -2344,13 +2392,13 @@ module M south = south.get # Side test: triad, medial and bounded boxes. - pts = mod1.getNonCollinears(ceiling.vertices, 3) + pts = mod1.nonCollinears(ceiling.vertices, 3) box01 = mod1.triadBox(pts) box11 = mod1.boundedBox(ceiling) expect(mod1.same?(box01, box11)).to be true expect(mod1.fits?(box01, ceiling)).to be true - pts = mod1.getNonCollinears(roof.vertices, 3) + pts = mod1.nonCollinears(roof.vertices, 3) box02 = mod1.medialBox(pts) box12 = mod1.boundedBox(roof) expect(mod1.same?(box02, box12)).to be true @@ -2462,7 +2510,7 @@ module M # Overlap between roof and vertically-cast ceiling onto roof plane. olap02 = mod1.overlap(roof, cast02) expect(olap02.size).to eq(3) # not 5 - expect(mod1.fits?(olap02, roof)).to be(true) + expect(mod1.fits?(olap02, roof)).to be true olap02.each { |pt| expect(pl2.pointOnPlane(pt)) } @@ -2731,48 +2779,243 @@ module M # Independent point. p7 = OpenStudio::Point3d.new(14, 20, -5) + p8 = OpenStudio::Point3d.new(-9, -9, -5) + + # Stress test 'to_p3Dv'. 4 valid input cases. + # Valid case #1: a single Point3d. + vtx = mod1.to_p3Dv(p0) + expect(vtx).to be_a(OpenStudio::Point3dVector) + expect(vtx[0]).to eq(p0) # same object ID + + # Valid case #2: a Point3dVector. + vtxx = OpenStudio::Point3dVector.new + vtxx << p0 + vtxx << p1 + vtxx << p2 + vtxx << p3 + vtx = mod1.to_p3Dv(vtxx) + expect(vtx).to be_a(OpenStudio::Point3dVector) + expect(vtx[ 0]).to eq(p0) # same object ID + expect(vtx[ 1]).to eq(p1) # same object ID + expect(vtx[ 2]).to eq(p2) # same object ID + expect(vtx[-1]).to eq(p3) # same object ID + + # Valid case #3: Surface vertices. + model = OpenStudio::Model::Model.new + surface = OpenStudio::Model::Surface.new(vtxx, model) + expect(surface.vertices).to be_an(Array) # not an OpenStudio::Point3dVector + expect(surface.vertices.size).to eq(4) + vtx = mod1.to_p3Dv(vtxx) + expect(vtx).to be_a(OpenStudio::Point3dVector) + expect(vtx.size).to eq(4) + expect(vtx[0]).to eq(p0) + expect(vtx[1]).to eq(p1) + expect(vtx[2]).to eq(p2) + expect(vtx[3]).to eq(p3) + + # Valid case #4: Array. + vtx = mod1.to_p3Dv([p0, p1, p2, p3]) + expect(vtx).to be_a(OpenStudio::Point3dVector) + expect(vtx.size).to eq(4) + expect(vtx[0]).to eq(p0) + expect(vtx[1]).to eq(p1) + expect(vtx[2]).to eq(p2) + expect(vtx[3]).to eq(p3) + expect(mod1.status).to eq(0) + + # Stress test 'nextUp'. Invalid case. + m0 = "Invalid 'points (2+)' arg #1 (OSut::nextUp)" + pt = mod1.nextUp([], p0) + expect(pt).to be nil + expect(mod1.warn?).to be true + expect(mod1.logs.size).to eq(1) + expect(mod1.logs.first[:message]).to eq(m0) + expect(mod1.clean!).to eq(DBG) + + # Valid case. + pt = mod1.nextUp([p0, p1, p2, p3], p0) + expect(pt).to be_a(OpenStudio::Point3d) + expect(pt).to eq(p1) + + pt = mod1.nextUp([p0, p0, p0], p0) + expect(pt).to be_a(OpenStudio::Point3d) + expect(pt).to eq(p0) + + # Stress test 'segments'. Invalid case. + sgs = mod1.segments(p3) + expect(sgs).to be_a(OpenStudio::Point3dVectorVector) + expect(sgs).to be_empty + expect(mod1.status).to eq(0) # nothing logged + + sgs = mod1.segments([p3, p3]) + expect(sgs).to be_a(OpenStudio::Point3dVectorVector) + expect(sgs).to be_empty + expect(mod1.status).to eq(0) # nothing logged + + # Valid case. + sgs = mod1.segments([p0, p1, p2, p3]) + expect(sgs).to be_a(OpenStudio::Point3dVectorVector) + expect(sgs.size).to eq(4) + expect(sgs).to respond_to(:first) + expect(sgs).to_not respond_to(:last) + expect(sgs[-1]).to be_an(Array) # not an OpenStudio::Point3dVector + + # Stress test 'uniques'. + m0 = "'n points' String? expecting Integer (OSut::uniques)" + + # Invalid number case - simply returns entire list of unique points. + uniks = mod1.uniques([p0, p1, p2, p3], "osut") + expect(uniks).to be_a(OpenStudio::Point3dVector) + expect(uniks.size).to eq(4) + expect(mod1.debug?).to be true + expect(mod1.logs.size).to eq(1) + expect(mod1.logs.first[:message]).to eq(m0) + expect(mod1.clean!).to eq(DBG) + + # Valid, basic case. + uniks = mod1.uniques([p0, p1, p2, p3]) + expect(uniks).to be_a(OpenStudio::Point3dVector) + expect(uniks.size).to eq(4) + + uniks = mod1.uniques([p0, p1, p2, p3], 0) + expect(uniks).to be_a(OpenStudio::Point3dVector) + expect(uniks.size).to eq(4) + + # Valid, first 3 points. + uniks = mod1.uniques([p0, p1, p2, p3], 3) + expect(uniks).to be_a(OpenStudio::Point3dVector) + expect(uniks.size).to eq(3) + + # Valid, last 3 points. + uniks = mod1.uniques([p0, p1, p2, p3], -3) + expect(uniks).to be_a(OpenStudio::Point3dVector) + expect(uniks.size).to eq(3) + + # Valid, n = 5: returns original 4 uniques points. + uniks = mod1.uniques([p0, p1, p2, p3], 5) + expect(uniks).to be_a(OpenStudio::Point3dVector) + expect(uniks.size).to eq(4) + + # Valid, n = -5: returns original 4 uniques points. + uniks = mod1.uniques([p0, p1, p2, p3], -5) + expect(uniks).to be_a(OpenStudio::Point3dVector) + expect(uniks.size).to eq(4) + + # Stress tests collinears. + m0 = "'n points' String? expecting Integer (OSut::collinears)" + + # Invalid case - raise DEBUG message, yet returns valid collinears. + colls = mod1.collinears([p0, p1, p3, p8], "osut") + expect(colls).to be_a(OpenStudio::Point3dVector) + expect(colls.size).to eq(1) + expect(colls[0]).to eq(p0) + expect(mod1.debug?).to be true + expect(mod1.logs.size).to eq(1) + expect(mod1.logs.first[:message]).to eq(m0) + expect(mod1.clean!).to eq(DBG) - collinears = mod1.getCollinears( [p0, p1, p2, p3] ) + # Valid, basic case + colls = mod1.collinears([p0, p1, p3, p8]) + expect(colls.size).to eq(1) + expect(colls[0]).to eq(p0) # same object ID + expect(mod1.same?(colls.first, p0)).to be true # more expensive way + + colls = mod1.collinears([p0, p1, p3, p8], 0) + expect(colls.size).to eq(1) + expect(colls[0]).to eq(p0) + + colls = mod1.collinears([p0, p1, p2, p3, p8]) + expect(colls.size).to eq(2) + expect(colls[ 0]).to eq(p0) + expect(colls[-1]).to eq(p1) + expect(mod1.pointAlongSegment?(p0, sgs.first)) # sg is an Array (size = 2) + + # Only 2 collinears, so request for first 3 is ignored. + colls = mod1.collinears([p0, p1, p2, p3, p8], 3) + expect(colls.size).to eq(2) + expect(mod1.same?(colls[0], p0)).to be true + expect(mod1.same?(colls[1], p1)).to be true + + # First collinear (out of 2). + collinears = mod1.collinears([p0, p1, p2, p3, p8], 1) expect(collinears.size).to eq(1) - expect(mod1.same?(collinears[0], p1)).to be true + expect(mod1.same?(collinears[0], p0)).to be true + + # Last collinear (out of 2). + colls = mod1.collinears([p0, p1, p2, p3, p8], -1) + expect(colls.size).to eq(1) + expect(mod1.same?(colls[0], p1)).to be true + + # First two vs last two: same result. + colls = mod1.collinears([p0, p1, p2, p3, p8], -2) + expect(colls.size).to eq(2) + expect(mod1.same?(colls[0], p0)).to be true + expect(mod1.same?(colls[1], p1)).to be true + + # Ignore n request when n.abs > number of actual collinears. + colls = mod1.collinears([p0, p1, p2, p3, p8], 6) + expect(colls.size).to eq(2) + expect(mod1.same?(colls[0], p0)).to be true + expect(mod1.same?(colls[1], p1)).to be true + + colls = mod1.collinears([p0, p1, p2, p3, p8], -6) + expect(colls.size).to eq(2) + expect(mod1.same?(colls[0], p0)).to be true + expect(mod1.same?(colls[1], p1)).to be true + + # Stress test pointAlongSegment? + m0 = "'points' String? expecting Array (OSut::to_p3Dv)" + + # Invalid case. + expect(mod1.pointAlongSegment?(p3, "osut")).to be false + expect(mod1.debug?).to be true + expect(mod1.logs.size).to eq(1) + expect(mod1.logs.first[:message]).to eq(m0) + expect(mod1.clean!).to eq(DBG) + + # Valid case. + pts = OpenStudio::Point3dVector.new + pts << p0 + pts << p1 + expect(mod1.pointAlongSegment?(p3, pts)).to be false # CASE a1: 2x end-to-end line segments (returns matching endpoints). expect(mod1.lineIntersects?( [p0, p1], [p1, p2] )).to be true - pt = mod1.getLineIntersection( [p0, p1], [p1, p2] ) + pt = mod1.lineIntersection( [p0, p1], [p1, p2] ) expect(mod1.same?(pt, p1)).to be true # CASE a2: as a1, sequence of line segment endpoints doesn't matter. expect(mod1.lineIntersects?( [p1, p0], [p1, p2] )).to be true - pt = mod1.getLineIntersection( [p1, p0], [p1, p2] ) + pt = mod1.lineIntersection( [p1, p0], [p1, p2] ) expect(mod1.same?(pt, p1)).to be true # CASE b1: 2x right-angle line segments, with 1x matching at corner. expect(mod1.lineIntersects?( [p1, p2], [p1, p3] )).to be true - pt = mod1.getLineIntersection( [p1, p2], [p2, p3] ) + pt = mod1.lineIntersection( [p1, p2], [p2, p3] ) expect(mod1.same?(pt, p2)).to be true # CASE b2: as b1, sequence of segments doesn't matter. expect(mod1.lineIntersects?( [p2, p3], [p1, p2] )).to be true - pt = mod1.getLineIntersection( [p2, p3], [p1, p2] ) + pt = mod1.lineIntersection( [p2, p3], [p1, p2] ) expect(mod1.same?(pt, p2)).to be true # CASE c: 2x right-angle line segments, yet disconnected. expect(mod1.lineIntersects?( [p0, p1], [p2, p3] )).to be false - expect(mod1.getLineIntersection( [p0, p1], [p2, p3] )).to be_nil + expect(mod1.lineIntersection( [p0, p1], [p2, p3] )).to be_nil # CASE d: 2x connected line segments, acute angle. expect(mod1.lineIntersects?( [p0, p2], [p3, p0] )).to be true - pt = mod1.getLineIntersection( [p0, p2], [p3, p0] ) + pt = mod1.lineIntersection( [p0, p2], [p3, p0] ) expect(mod1.same?(pt, p0)).to be true # CASE e1: 2x disconnected line segments, right angle. expect(mod1.lineIntersects?( [p0, p2], [p4, p6] )).to be true - pt = mod1.getLineIntersection( [p0, p2], [p4, p6] ) + pt = mod1.lineIntersection( [p0, p2], [p4, p6] ) expect(mod1.same?(pt, p5)).to be true # CASE e2: as e1, sequence of line segment endpoints doesn't matter. expect(mod1.lineIntersects?( [p0, p2], [p6, p4] )).to be true - pt = mod1.getLineIntersection( [p0, p2], [p6, p4] ) + pt = mod1.lineIntersection( [p0, p2], [p6, p4] ) expect(mod1.same?(pt, p5)).to be true # Point ENTIRELY within (vs outside) a polygon. @@ -2784,6 +3027,7 @@ module M expect(mod1.pointWithinPolygon?(p5, [p0, p1, p2, p3])).to be true expect(mod1.pointWithinPolygon?(p6, [p0, p1, p2, p3])).to be false expect(mod1.pointWithinPolygon?(p7, [p0, p1, p2, p3])).to be true + expect(mod1.status).to be_zero # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # @@ -2800,6 +3044,7 @@ module M expect(mod1.logs.first[:message]).to include("Empty 'plane'") expect(mod1.clean!).to eq(DBG) + # Test self-intersecting polygon. If reactivated, OpenStudio logs to stdout: # [utilities.Transformation] <1> Cannot compute outward normal for vertices # vtx = OpenStudio::Point3dVector.new @@ -2807,11 +3052,11 @@ module M # vtx << OpenStudio::Point3d.new( 0, 0, 10) # vtx << OpenStudio::Point3d.new(20, 0, 0) # vtx << OpenStudio::Point3d.new( 0, 0, 0) - # - # expect(mod1.poly(vtx)).to be_empty - # expect(mod1.status).to eq(ERR) - # expect(mod1.logs.size).to eq(1) - # expect(mod1.logs.first[:message]).to include("'(unaligned) points'") + + # Original polygon remains unaltered. + # vtx2 = mod1.poly(vtx) + # expect(mod1.same?(vtx, vtx2)).to be true + # expect(mod1.status).to eq(0) # expect(mod1.clean!).to eq(DBG) # Regular polygon, counterclockwise yet not UpperLeftCorner (ULC). @@ -2820,13 +3065,13 @@ module M vtx << OpenStudio::Point3d.new( 0, 0, 10) vtx << OpenStudio::Point3d.new( 0, 0, 0) - segments = mod1.getSegments(vtx) - expect(segments).to be_a(OpenStudio::Point3dVectorVector) - expect(segments.size).to eq(3) + sgments = mod1.segments(vtx) + expect(sgments).to be_a(OpenStudio::Point3dVectorVector) + expect(sgments.size).to eq(3) - segments.each_with_index do |segment, i| - unless mod1.xyz?(segment, :x, segment.first.x) - vplane = mod1.verticalPlane(segment.first, segment.last) + sgments.each_with_index do |sgment, i| + unless mod1.xyz?(sgment, :x, sgment[0].x) + vplane = mod1.verticalPlane(sgment[0], sgment[-1]) expect(vplane).to be_a(OpenStudio::Plane) end end @@ -3617,7 +3862,7 @@ module M expect(mod1.level).to eq(DBG) expect(mod1.clean!).to eq(DBG) - # Modified NREL SEB model' + # Modified NREL SEB model. file = File.join(__dir__, "files/osms/out/seb_ext2.osm") path = OpenStudio::Path.new(file) model = translator.loadModel(path) @@ -4384,7 +4629,7 @@ module M # "Story 1 West Perimeter Space" : "Surface 6" model.getSpaces.each do |space| - rufs = mod1.getRoofs(space) + rufs = mod1.roofs(space) expect(rufs.size).to eq(1) ruf = rufs.first expect(ruf).to be_a(OpenStudio::Model::Surface) @@ -4431,7 +4676,7 @@ module M model.getSpaces.each do |space| next unless space.partofTotalFloorArea - rufs = mod1.getRoofs(space) + rufs = mod1.roofs(space) expect(rufs.size).to eq(1) ruf = rufs.first expect(ruf).to be_a(OpenStudio::Model::Surface) @@ -4555,7 +4800,7 @@ module M ld2 = OpenStudio::Point3d.new( 8, 3, 0) sg1 = OpenStudio::Point3d.new(12, 14, 0) sg2 = OpenStudio::Point3d.new(12, 6, 0) - expect(mod1.getLineIntersection([sg1, sg2], [ld1, ld2])).to be_nil + expect(mod1.lineIntersection([sg1, sg2], [ld1, ld2])).to be_nil # To support multiple polygon inserts within a larger polygon, subset boxes # must be first 'aligned' (along a temporary XY plane) in a systematic way @@ -4752,7 +4997,7 @@ module M # TOTAL attic roof area, including overhangs. roofs = mod1.facets(attic, "Outdoors", "RoofCeiling") - rufs = mod1.getRoofs(model.getSpaces) + rufs = mod1.roofs(model.getSpaces) total1 = roofs.sum(&:grossArea) total2 = rufs.sum(&:grossArea) expect(total1.round(2)).to eq(total2.round(2)) @@ -4819,11 +5064,11 @@ module M expect((tot1 - net).round(2)).to eq(sky_area1.round(2)) # In absence of skylight wells (more importantly, in absence of leader lines - # anchoring skylight base surfaces), OSut's 'getRoofs' & 'grossRoofArea' + # anchoring skylight base surfaces), OSut's 'roofs' & 'grossRoofArea' # report not only on newly-added base surfaces (or their areas), but also # overalpping areas of attic roofs above. Unfortunately, these become # unreliable with newly-added skylight wells. - rfs2 = mod1.getRoofs(core) + rfs2 = mod1.roofs(core) tot2 = rfs2.sum(&:grossArea) expect(tot2.round(2)).to eq(tot1.round(2)) expect(tot2.round(2)).to eq(mod1.grossRoofArea(core).round(2)) @@ -4841,11 +5086,11 @@ module M # if a higher-level application, relying on 'addSkylights' (e.g. an # OpenStudio measure), stores its output for subsequent reporting purposes. - # Deeper dive: Why are OSut's 'getRoofs' and 'grossRoofArea' unreliable + # Deeper dive: Why are OSut's 'roofs' and 'grossRoofArea' unreliable # with leader lines? Both rely on OSut's 'overlaps?', itself relying on # OpenStudio's 'join' and 'intersect': if neither are successful in joining # (or intersecting) 2x polygons (e.g. attic roof vs cast core ceiling), - # there can be no identifiable overlap. In such cases, both 'getRoofs' and + # there can be no identifiable overlap. In such cases, both 'roofs' and # 'grossRoofArea' ignore overlapping attic roofs. A demo: roof_north = model.getSurfaceByName("Attic_roof_north") core_ceiling = model.getSurfaceByName("Core_ZN_ceiling") @@ -4874,11 +5119,11 @@ module M expect(OpenStudio.join(a_roof_north, c_core_ceiling, TOL2)).to be_empty expect(OpenStudio.intersect(a_roof_north, c_core_ceiling, TOL)).to be_empty - # A future revision of OSut's 'getRoofs' and 'grossRoofArea' would require: + # A future revision of OSut's 'roofs' and 'grossRoofArea' would require: # - a new method identifying leader lines amongts surface vertices # - a new method identifying surface cutouts amongst surface vertices # - a method to purge both leader lines and cutouts from surface vertices - # - have 'getRoofs' & 'grossRoofArea' rely on the remaining outer vertices + # - have 'roofs' & 'grossRoofArea' rely on the remaining outer vertices # ... @todo? # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # @@ -5103,8 +5348,8 @@ module M gra_bulk = mod1.grossRoofArea(bulk) gra_fine = mod1.grossRoofArea(fine) - bulk_roof_m2 = mod1.getRoofs(bulk).sum(&:grossArea) - fine_roof_m2 = mod1.getRoofs(fine).sum(&:grossArea) + bulk_roof_m2 = mod1.roofs(bulk).sum(&:grossArea) + fine_roof_m2 = mod1.roofs(fine).sum(&:grossArea) expect(gra_bulk.round(2)).to eq(bulk_roof_m2.round(2)) expect(gra_fine.round(2)).to eq(fine_roof_m2.round(2)) @@ -5393,6 +5638,17 @@ module M expect(surface).to be_a(OpenStudio::Model::Surface) expect(surface.vertices.size).to eq(12) expect(surface.grossArea).to be_within(TOL).of(5 * 20 - 1) + + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + # Invalid input case. + plates = ["osut"] + slab = mod1.genSlab(plates, z0) + expect(mod1.debug?).to be true + expect(mod1.logs.size).to eq(1) + expect(mod1.logs.first[:message]).to include("String? expecting Hash") + expect(slab).to be_a(OpenStudio::Point3dVector) + expect(slab).to be_empty + end it "checks roller shades" do @@ -5468,4 +5724,90 @@ module M file = File.join(__dir__, "files/osms/out/seb_ext5.osm") model.save(file, true) end + + it "checks space height & width" do + translator = OpenStudio::OSVersion::VersionTranslator.new + expect(mod1.reset(DBG)).to eq(DBG) + expect(mod1.level).to eq(DBG) + expect(mod1.clean!).to eq(DBG) + + file = File.join(__dir__, "files/osms/in/warehouse.osm") + path = OpenStudio::Path.new(file) + model = translator.loadModel(path) + expect(model).to_not be_empty + model = model.get + + fine = model.getSpaceByName("Zone2 Fine Storage") + expect(fine).to_not be_empty + fine = fine.get + + # The Fine Storage space has 2 floors, at different Z-axis levels: + # - main ground floor (slab on grade), Z=0.00m + # - mezzanine floor, adjacent to the office space ceiling below, Z=4.27m + expect(mod1.facets(fine, "all", "floor").size).to eq(2) + groundfloor = model.getSurfaceByName("Fine Storage Floor") + mezzanine = model.getSurfaceByName("Office Roof Reversed") + expect(groundfloor).to_not be_empty + expect(mezzanine).to_not be_empty + groundfloor = groundfloor.get + mezzanine = mezzanine.get + + # The ground floor is L-shaped, floor surfaces have differenet Z=axis + # levels, etc. In the context of codes/standards like ASHRAE 90.1 or the + # Canadian NECB, determining what constitutes a space's 'height' and/or + # 'width' matters, namely with regards to geometry-based LPD rules (e.g. + # adjustments based on corridor 'width'). Not stating here what the + # definitive answers should be in all cases. There are however a few OSut + # functions that may be helpful. + # + # OSut's 'aligned' height and width functions were initially developed for + # non-flat surfaces, like walls and sloped roofs - particularly useful when + # such surfaces are rotated in 3D space. It's somewhat less intuitive when + # applied to horizontal surfaces like floors. In a nutshell, the functions + # lay out the surface in a 2D grid, aligning it along its 'bounded box'. It + # then determines a bounding box around the surface, once aligned: + # - 'aligned height' designates the narrowest edge of the bounding box + # - 'aligned width' designates the widest edge of the bounding box + # + # Useful? In some circumstances, maybe. One can argue that these may be of + # limited use for width-based LPD adjustment calculations. + expect(mod1.alignedHeight(groundfloor)).to be_within(TOL).of(30.48) + expect(mod1.alignedWidth(groundfloor)).to be_within(TOL).of(45.72) + expect(mod1.alignedHeight(mezzanine)).to be_within(TOL).of(9.14) + expect(mod1.alignedWidth(mezzanine)).to be_within(TOL).of(25.91) + + # OSut's 'spaceHeight' and 'spaceWidth' are more suitable for height- or + # width-based LPD adjustement calculations. OSut sets a space's width as + # the length of the narrowest edge of the largest bounded box that fits + # within a collection of neighbouring floor surfaces. This is considered + # reasonable for a long corridor, with varying widths along its full + # length (e.g. occasional alcoves). + # + # Achtung! The function can be time consuming (multiple iterations) for + # very convoluted spaces (e.g. long corridors with multiple concavities). + expect(mod1.spaceHeight(fine)).to be_within(TOL).of(8.53) + expect(mod1.spaceWidth(fine)).to be_within(TOL).of(21.33) + + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + file = File.join(__dir__, "files/osms/out/seb_sky.osm") + path = OpenStudio::Path.new(file) + model = translator.loadModel(path) + expect(model).to_not be_empty + model = model.get + + openarea = model.getSpaceByName("Open area 1") + expect(openarea).to_not be_empty + openarea = openarea.get + + floor = mod1.facets(openarea, "all", "floor") + expect(floor.size).to eq(1) + floor = floor.first + + expect(mod1.alignedHeight(floor)).to be_within(TOL).of(6.88) + expect(mod1.alignedWidth(floor)).to be_within(TOL).of(8.22) + expect(mod1.spaceHeight(openarea)).to be_within(TOL).of(3.96) + expect(mod1.spaceWidth(openarea)).to be_within(TOL).of(3.77) + + expect(mod1.status).to eq(0) + end end