lib/osut/utils.rb in osut-0.2.7 vs lib/osut/utils.rb in osut-0.2.8

- old
+ new

@@ -1,8 +1,8 @@ # BSD 3-Clause License # -# Copyright (c) 2022, Denis Bourgeois +# Copyright (c) 2022-2023, Denis Bourgeois # All rights reserved. # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # @@ -57,11 +57,11 @@ # # - CONDITIONED space: an ENCLOSED space that has a heating and/or # cooling system of sufficient size to maintain temperatures suitable # for HUMAN COMFORT: # - COOLED: cooled by a system >= 10 W/m2 - # - HEATED: heated by a system e.g., >= 50 W/m2 in Climate Zone CZ-7 + # - HEATED: heated by a system, e.g. >= 50 W/m2 in Climate Zone CZ-7 # - INDIRECTLY: heated or cooled via adjacent space(s) provided: # - UA of adjacent surfaces > UA of other surfaces # or # - intentional air transfer from HEATED/COOLED space > 3 ACH # @@ -87,11 +87,11 @@ # # "[...] the temperature of which is controlled to limit variation in # response to the exterior ambient temperature by the provision, either # DIRECTLY or INDIRECTLY, of heating or cooling [...]". Although criteria # differ (e.g., not sizing-based), the general idea is sufficiently similar - # to ASHRAE 90.1 (e.g., heating and/or cooling based, no distinction for + # to ASHRAE 90.1 (e.g. heating and/or cooling based, no distinction for # INDIRECTLY conditioned spaces like plenums). # # SEMI-HEATED spaces are also a defined NECB term, but again the distinction # is based on desired/intended design space setpoint temperatures - not # system sizing criteria. No further treatment is implemented here to @@ -107,20 +107,20 @@ # In light of the above, methods here are designed without a priori knowledge # of explicit system sizing choices or access to iterative autosizing # processes. As discussed in greater detail elswhere, methods are developed to # rely on zoning info and/or "intended" temperature setpoints. # - # For an OpenStudio model (OSM) in an incomplete or preliminary state, e.g. - # holding fully-formed ENCLOSED spaces without thermal zoning information or - # setpoint temperatures (early design stage assessments of form, porosity or - # envelope), all OSM spaces will be considered CONDITIONED, presuming - # setpoints of ~21°C (heating) and ~24°C (cooling). + # For an OpenStudio model in an incomplete or preliminary state, e.g. holding + # fully-formed ENCLOSED spaces without thermal zoning information or setpoint + # temperatures (early design stage assessments of form, porosity or envelope), + # all OpenStudio spaces will be considered CONDITIONED, presuming setpoints of + # ~21°C (heating) and ~24°C (cooling). # - # If ANY valid space/zone-specific temperature setpoints are found in the OSM, - # spaces/zones WITHOUT valid heating or cooling setpoints are considered as - # UNCONDITIONED or UNENCLOSED spaces (like attics), or INDIRECTLY CONDITIONED - # spaces (like plenums), see "plenum?" method. + # If ANY valid space/zone-specific temperature setpoints are found in the + # OpenStudio model, spaces/zones WITHOUT valid heating or cooling setpoints + # are considered as UNCONDITIONED or UNENCLOSED spaces (like attics), or + # INDIRECTLY CONDITIONED spaces (like plenums), see "plenum?" method. ## # Return min & max values of a schedule (ruleset). # # @param sched [OpenStudio::Model::ScheduleRuleset] schedule @@ -137,10 +137,11 @@ mth = "OSut::#{__callee__}" cl = OpenStudio::Model::ScheduleRuleset res = { min: nil, max: nil } return invalid("sched", mth, 1, DBG, res) unless sched.respond_to?(NS) + id = sched.nameString return mismatch(id, sched, cl, mth, DBG, res) unless sched.is_a?(cl) profiles = [] profiles << sched.defaultDaySchedule @@ -184,10 +185,11 @@ mth = "OSut::#{__callee__}" cl = OpenStudio::Model::ScheduleConstant res = { min: nil, max: nil } return invalid("sched", mth, 1, DBG, res) unless sched.respond_to?(NS) + id = sched.nameString return mismatch(id, sched, cl, mth, DBG, res) unless sched.is_a?(cl) valid = sched.value.is_a?(Numeric) mismatch("'#{id}' value", sched.value, Numeric, mth, ERR, res) unless valid @@ -216,10 +218,11 @@ vals = [] prev_str = "" res = { min: nil, max: nil } return invalid("sched", mth, 1, DBG, res) unless sched.respond_to?(NS) + id = sched.nameString return mismatch(id, sched, cl, mth, DBG, res) unless sched.is_a?(cl) sched.extensibleGroups.each do |eg| if prev_str.include?("until") @@ -229,13 +232,15 @@ str = eg.getString(0) prev_str = str.get.downcase unless str.empty? end return empty("'#{id}' values", mth, ERR, res) if vals.empty? + ok = vals.min.is_a?(Numeric) && vals.max.is_a?(Numeric) log(ERR, "Non-numeric values in '#{id}' (#{mth})") unless ok return res unless ok + res[:min] = vals.min res[:max] = vals.max res end @@ -246,23 +251,25 @@ # @param sched [OpenStudio::Model::ScheduleInterval] schedule # # @return [Hash] min: (Float), max: (Float) # @return [Hash] min: nil, max: nil (if invalid input) def scheduleIntervalMinMax(sched = nil) - mth = "OSut::#{__callee__}" - cl = OpenStudio::Model::ScheduleInterval - vals = [] - prev_str = "" - res = { min: nil, max: nil } + mth = "OSut::#{__callee__}" + cl = OpenStudio::Model::ScheduleInterval + vals = [] + res = { min: nil, max: nil } return invalid("sched", mth, 1, DBG, res) unless sched.respond_to?(NS) + id = sched.nameString return mismatch(id, sched, cl, mth, DBG, res) unless sched.is_a?(cl) + vals = sched.timeSeries.values - ok = vals.min.is_a?(Numeric) && vals.max.is_a?(Numeric) + ok = vals.min.is_a?(Numeric) && vals.max.is_a?(Numeric) log(ERR, "Non-numeric values in '#{id}' (#{mth})") unless ok return res unless ok + res[:min] = vals.min res[:max] = vals.max res end @@ -287,10 +294,11 @@ mth = "OSut::#{__callee__}" cl = OpenStudio::Model::ThermalZone res = { spt: nil, dual: false } return invalid("zone", mth, 1, DBG, res) unless zone.respond_to?(NS) + id = zone.nameString return mismatch(id, zone, cl, mth, DBG, res) unless zone.is_a?(cl) # Zone radiant heating? Get schedule from radiant system. zone.equipment.each do |equip| @@ -367,10 +375,11 @@ end end end return res if zone.thermostat.empty? + tstat = zone.thermostat.get res[:spt] = nil unless tstat.to_ThermostatSetpointDualSetpoint.empty? && tstat.to_ZoneControlThermostatStagedDualSetpoint.empty? @@ -425,10 +434,11 @@ unless sched.to_ScheduleYear.empty? sched = sched.to_ScheduleYear.get sched.getScheduleWeeks.each do |week| next if week.winterDesignDaySchedule.empty? + dd = week.winterDesignDaySchedule.get next unless dd.values.empty? res[:spt] = dd.values.max unless res[:spt] res[:spt] = dd.values.max if res[:spt] < dd.values.max @@ -477,10 +487,11 @@ mth = "OSut::#{__callee__}" cl = OpenStudio::Model::ThermalZone res = { spt: nil, dual: false } return invalid("zone", mth, 1, DBG, res) unless zone.respond_to?(NS) + id = zone.nameString return mismatch(id, zone, cl, mth, DBG, res) unless zone.is_a?(cl) # Zone radiant cooling? Get schedule from radiant system. zone.equipment.each do |equip| @@ -544,10 +555,11 @@ end end end return res if zone.thermostat.empty? + tstat = zone.thermostat.get res[:spt] = nil unless tstat.to_ThermostatSetpointDualSetpoint.empty? && tstat.to_ZoneControlThermostatStagedDualSetpoint.empty? @@ -602,10 +614,11 @@ unless sched.to_ScheduleYear.empty? sched = sched.to_ScheduleYear.get sched.getScheduleWeeks.each do |week| next if week.summerDesignDaySchedule.empty? + dd = week.summerDesignDaySchedule.get next unless dd.values.empty? res[:spt] = dd.values.min unless res[:spt] res[:spt] = dd.values.min if res[:spt] > dd.values.min @@ -689,14 +702,17 @@ # 'standards spacetype'). mth = "OSut::#{__callee__}" cl = OpenStudio::Model::Space return invalid("space", mth, 1, DBG, false) unless space.respond_to?(NS) + id = space.nameString return mismatch(id, space, cl, mth, DBG, false) unless space.is_a?(cl) + valid = loops == true || loops == false return invalid("loops", mth, 2, DBG, false) unless valid + valid = setpoints == true || setpoints == false return invalid("setpoints", mth, 3, DBG, false) unless valid unless space.thermalZone.empty? zone = space.thermalZone.get @@ -712,15 +728,15 @@ end unless space.spaceType.empty? type = space.spaceType.get return type.nameString.downcase == "plenum" # C + end - unless type.standardsSpaceType.empty? - type = type.standardsSpaceType.get - return type.downcase == "plenum" # C - end + unless type.standardsSpaceType.empty? + type = type.standardsSpaceType.get + return type.downcase == "plenum" # C end false end @@ -749,10 +765,11 @@ next unless l.lowerLimitValue.get.to_i == 0 next unless l.upperLimitValue.get.to_i == 1 next unless l.numericType.get.downcase == "discrete" next unless l.unitType.downcase == "availability" next unless l.nameString.downcase == "hvac operation scheduletypelimits" + limits = l end unless limits limits = OpenStudio::Model::ScheduleTypeLimits.new(model) @@ -769,10 +786,11 @@ off = OpenStudio::Model::ScheduleDay.new(model, 0) # Seasonal availability start/end dates. year = model.yearDescription return empty("yearDescription", mth, ERR) if year.empty? + year = year.get may01 = year.makeDate(OpenStudio::MonthOfYear.new("May"), 1) oct31 = year.makeDate(OpenStudio::MonthOfYear.new("Oct"), 31) case avl.to_s.downcase @@ -848,35 +866,40 @@ schedule = OpenStudio::Model::ScheduleRuleset.new(model) schedule.setName(nom) ok = schedule.setScheduleTypeLimits(limits) log(ERR, "'#{nom}': Can't set schedule type limits (#{mth})") unless ok return nil unless ok + ok = schedule.defaultDaySchedule.addValue(time, val) log(ERR, "'#{nom}': Can't set default day schedule (#{mth})") unless ok return nil unless ok + schedule.defaultDaySchedule.setName(dft) unless tag.empty? rule = OpenStudio::Model::ScheduleRule.new(schedule, sch) rule.setName(tag) ok = rule.setStartDate(may01) log(ERR, "'#{tag}': Can't set start date (#{mth})") unless ok return nil unless ok + ok = rule.setEndDate(oct31) log(ERR, "'#{tag}': Can't set end date (#{mth})") unless ok return nil unless ok + ok = rule.setApplyAllDays(true) log(ERR, "'#{tag}': Can't apply to all days (#{mth})") unless ok return nil unless ok + rule.daySchedule.setName(day) end schedule end ## - # Validate if default construction set holds a base ground construction. + # Validate if default construction set holds a base construction. # # @param set [OpenStudio::Model::DefaultConstructionSet] a default set # @param bse [OpensStudio::Model::ConstructionBase] a construction base # @param gr [Bool] true if ground-facing surface # @param ex [Bool] true if exterior-facing surface @@ -888,21 +911,27 @@ mth = "OSut::#{__callee__}" cl1 = OpenStudio::Model::DefaultConstructionSet cl2 = OpenStudio::Model::ConstructionBase return invalid("set", mth, 1, DBG, false) unless set.respond_to?(NS) + id = set.nameString return mismatch(id, set, cl1, mth, DBG, false) unless set.is_a?(cl1) return invalid("base", mth, 2, DBG, false) unless bse.respond_to?(NS) + id = bse.nameString return mismatch(id, bse, cl2, mth, DBG, false) unless bse.is_a?(cl2) + valid = gr == true || gr == false return invalid("ground", mth, 3, DBG, false) unless valid + valid = ex == true || ex == false return invalid("exterior", mth, 4, DBG, false) unless valid + valid = typ.respond_to?(:to_s) return invalid("surface typ", mth, 4, DBG, false) unless valid + type = typ.to_s.downcase valid = type == "floor" || type == "wall" || type == "roofceiling" return invalid("surface type", mth, 5, DBG, false) unless valid constructions = nil @@ -957,19 +986,22 @@ cl1 = OpenStudio::Model::Model cl2 = OpenStudio::Model::Surface return mismatch("model", model, cl1, mth) unless model.is_a?(cl1) return invalid("s", mth, 2) unless s.respond_to?(NS) + id = s.nameString return mismatch(id, s, cl2, mth) unless s.is_a?(cl2) ok = s.isConstructionDefaulted log(ERR, "'#{id}' construction not defaulted (#{mth})") unless ok return nil unless ok return empty("'#{id}' construction", mth, ERR) if s.construction.empty? + base = s.construction.get return empty("'#{id}' space", mth, ERR) if s.space.empty? + space = s.space.get type = s.surfaceType ground = false exterior = false @@ -1041,16 +1073,18 @@ def thickness(lc = nil) mth = "OSut::#{__callee__}" cl = OpenStudio::Model::LayeredConstruction return invalid("lc", mth, 1, DBG, 0.0) unless lc.respond_to?(NS) + id = lc.nameString return mismatch(id, lc, cl, mth, DBG, 0.0) unless lc.is_a?(cl) ok = standardOpaqueLayers?(lc) log(ERR, "'#{id}' holds non-StandardOpaqueMaterial(s) (#{mth})") unless ok return 0.0 unless ok + thickness = 0.0 lc.layers.each { |m| thickness += m.thickness } thickness end @@ -1111,24 +1145,28 @@ mth = "OSut::#{__callee__}" cl1 = OpenStudio::Model::LayeredConstruction cl2 = Numeric return invalid("lc", mth, 1, DBG, 0.0) unless lc.respond_to?(NS) + id = lc.nameString + return mismatch(id, lc, cl1, mth, DBG, 0.0) unless lc.is_a?(cl1) return mismatch("film", film, cl2, mth, DBG, 0.0) unless film.is_a?(cl2) return mismatch("temp K", t, cl2, mth, DBG, 0.0) unless t.is_a?(cl2) - t += 273.0 # °C to K + + t += 273.0 # °C to K return negative("temp K", mth, DBG, 0.0) if t < 0 return negative("film", mth, DBG, 0.0) if film < 0 rsi = film lc.layers.each do |m| # Fenestration materials first (ignoring shades, screens, etc.) empty = m.to_SimpleGlazing.empty? return 1 / m.to_SimpleGlazing.get.uFactor unless empty + empty = m.to_StandardGlazing.empty? rsi += m.to_StandardGlazing.get.thermalResistance unless empty empty = m.to_RefractionExtinctionGlazing.empty? rsi += m.to_RefractionExtinctionGlazing.get.thermalResistance unless empty empty = m.to_Gas.empty? @@ -1165,10 +1203,11 @@ cl = OpenStudio::Model::LayeredConstruction res = { index: nil, type: nil, r: 0.0 } i = 0 # iterator 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) lc.layers.each do |m| unless m.to_MasslessOpaqueMaterial.empty? @@ -1219,10 +1258,11 @@ cl2 = OpenStudio::Model::PlanarSurfaceGroup res = { t: nil, r: nil } return mismatch("model", model, cl1, mth, DBG, res) unless model.is_a?(cl1) return invalid("group", mth, 2, DBG, res) unless group.respond_to?(NS) + id = group.nameString return mismatch(id, group, cl2, mth, DBG, res) unless group.is_a?(cl2) res[:t] = group.siteTransformation res[:r] = group.directionofRelativeNorth + model.getBuilding.northAxis @@ -1242,14 +1282,14 @@ mth = "OSut::#{__callee__}" cl1 = OpenStudio::Vector3d cl2 = Numeric return mismatch("vector", v, cl1, mth, DBG, v) unless v.is_a?(cl1) - return mismatch("x", v.x, cl2, mth, DBG, v) unless v.x.respond_to?(:to_f) - return mismatch("y", v.y, cl2, mth, DBG, v) unless v.y.respond_to?(:to_f) - return mismatch("z", v.z, cl2, mth, DBG, v) unless v.z.respond_to?(:to_f) - return mismatch("m", m, cl2, mth, DBG, v) unless m.respond_to?(:to_f) + return mismatch("x", v.x, cl2, mth, DBG, v) unless v.x.respond_to?(:to_f) + return mismatch("y", v.y, cl2, mth, DBG, v) unless v.y.respond_to?(:to_f) + return mismatch("z", v.z, cl2, mth, DBG, v) unless v.z.respond_to?(:to_f) + return mismatch("m", m, cl2, mth, DBG, v) unless m.respond_to?(:to_f) OpenStudio::Vector3d.new(m * v.x, m * v.y, m * v.z) end ## @@ -1264,10 +1304,11 @@ cl2 = OpenStudio::Point3d v = OpenStudio::Point3dVector.new valid = pts.is_a?(cl1) || pts.is_a?(Array) return mismatch("points", pts, cl1, mth, DBG, v) unless valid + pts.each { |pt| mismatch("pt", pt, cl2, mth, ERR, v) unless pt.is_a?(cl2) } pts.each { |pt| v << OpenStudio::Point3d.new(pt.x, pt.y, 0) } v end @@ -1309,27 +1350,33 @@ # XY-plane transformation matrix ... needs to be clockwise for boost. ft = OpenStudio::Transformation.alignFace(p1) ft_p1 = flatZ( (ft.inverse * p1) ) return false if ft_p1.empty? + cw = OpenStudio.pointInPolygon(ft_p1.first, ft_p1, TOL) ft_p1 = flatZ( (ft.inverse * p1).reverse ) unless cw ft_p2 = flatZ( (ft.inverse * p2).reverse ) unless cw ft_p2 = flatZ( (ft.inverse * p2) ) if cw return false if ft_p2.empty? + area1 = OpenStudio.getArea(ft_p1) area2 = OpenStudio.getArea(ft_p2) return empty("#{i1} area", mth, ERR, a) if area1.empty? return empty("#{i2} area", mth, ERR, a) if area2.empty? + area1 = area1.get area2 = area2.get union = OpenStudio.join(ft_p1, ft_p2, TOL2) return false if union.empty? + union = union.get area = OpenStudio.getArea(union) return empty("#{i1}:#{i2} union area", mth, ERR, a) if area.empty? + area = area.get + return false if area < TOL return true if (area - area2).abs < TOL return false if (area - area2).abs > TOL true @@ -1374,26 +1421,31 @@ ft = OpenStudio::Transformation.alignFace(p1) ft_p1 = flatZ( (ft.inverse * p1) ) ft_p2 = flatZ( (ft.inverse * p2) ) return false if ft_p1.empty? return false if ft_p2.empty? + cw = OpenStudio.pointInPolygon(ft_p1.first, ft_p1, TOL) ft_p1 = flatZ( (ft.inverse * p1).reverse ) unless cw ft_p2 = flatZ( (ft.inverse * p2).reverse ) unless cw return false if ft_p1.empty? return false if ft_p2.empty? + area1 = OpenStudio.getArea(ft_p1) area2 = OpenStudio.getArea(ft_p2) return empty("#{i1} area", mth, ERR, a) if area1.empty? return empty("#{i2} area", mth, ERR, a) if area2.empty? + area1 = area1.get area2 = area2.get union = OpenStudio.join(ft_p1, ft_p2, TOL2) return false if union.empty? + union = union.get area = OpenStudio.getArea(union) return empty("#{i1}:#{i2} union area", mth, ERR, a) if area.empty? + area = area.get return false if area < TOL true end @@ -1413,16 +1465,19 @@ vrsn = OpenStudio.openStudioVersion.split(".").map(&:to_i).join.to_i valid = p1.is_a?(OpenStudio::Point3dVector) || p1.is_a?(Array) return mismatch("pts", p1, cl1, mth, DBG, p1) unless valid return empty("pts", mth, ERR, p1) if p1.empty? + valid = p1.size == 3 || p1.size == 4 iv = true if p1.size == 4 return invalid("pts", mth, 1, DBG, p1) unless valid return invalid("width", mth, 2, DBG, p1) unless w.respond_to?(:to_f) + w = w.to_f return p1 if w < 0.0254 + v = v.to_i if v.respond_to?(:to_i) v = 0 unless v.respond_to?(:to_i) v = vrsn if v.zero? p1.each { |x| return mismatch("p", x, cl, mth, ERR, p1) unless x.is_a?(cl) } @@ -1430,19 +1485,22 @@ unless v < 340 # XY-plane transformation matrix ... needs to be clockwise for boost. ft = OpenStudio::Transformation::alignFace(p1) ft_pts = flatZ( (ft.inverse * p1) ) return p1 if ft_pts.empty? + cw = OpenStudio::pointInPolygon(ft_pts.first, ft_pts, TOL) ft_pts = flatZ( (ft.inverse * p1).reverse ) unless cw offset = OpenStudio.buffer(ft_pts, w, TOL) return p1 if offset.empty? + offset = offset.get offset = ft * offset if cw offset = (ft * offset).reverse unless cw pz = OpenStudio::Point3dVector.new offset.each { |o| pz << OpenStudio::Point3d.new(o.x, o.y, o.z ) } + return pz else # brute force approach pz = {} pz[:A] = {} pz[:B] = {}