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] = {}