Module: TBD

Extended by:
OSut, TBD
Included in:
TBD
Defined in:
lib/tbd/ua.rb,
lib/tbd.rb,
lib/tbd/geo.rb,
lib/tbd/psi.rb,
lib/tbd/version.rb

Overview

MIT License

Copyright © 2020-2024 Denis Bourgeois & Dan Macumber

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

Defined Under Namespace

Classes: KHI, PSI

Constant Summary collapse

TOL =

default distance tolerance (m)

OSut::TOL.dup
TOL2 =

default area tolerance (m2)

OSut::TOL2.dup
DBG =

github.com/rd2/oslg

OSut::DEBUG.dup
INF =

github.com/rd2/oslg

OSut::INFO.dup
WRN =

github.com/rd2/oslg

OSut::WARN.dup
ERR =

github.com/rd2/oslg

OSut::ERR.dup
FTL =

github.com/rd2/oslg

OSut::FATAL.dup
NS =

OpenStudio IdfObject nameString method

OSut::NS.dup
VERSION =

TBD release version

"3.4.3".freeze

Instance Method Summary collapse

Instance Method Details

#concave?(s1 = nil, s2 = nil) ⇒ Bool, false

Validates whether edge surfaces form a concave angle, as seen from outside.

Parameters:

  • s1 (Hash) (defaults to: nil)

    first TBD surface

  • s2 (Hash) (defaults to: nil)

    second TBD surface

Options Hash (s1):

  • :normal (Topolys::Vector3D)

    surface normal vector

  • :polar (Topolys::Vector3D)

    vector around edge

  • :angle (Numeric)

    polar angle vs reference (e.g. North, Zenith)

Returns:

  • (Bool)

    true if angle between surfaces is concave

  • (false)

    if invalid input (see logs)



622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
# File 'lib/tbd/geo.rb', line 622

def concave?(s1 = nil, s2 = nil)
  mth = "TBD::#{__callee__}"
  return mismatch("s1", s1, Hash, mth, DBG, false) unless s1.is_a?(Hash)
  return mismatch("s2", s2, Hash, mth, DBG, false) unless s2.is_a?(Hash)
  return false if s1 == s2

  return hashkey("s1", s1,  :angle, mth, DBG, false) unless s1.key?(:angle)
  return hashkey("s2", s2,  :angle, mth, DBG, false) unless s2.key?(:angle)
  return hashkey("s1", s1, :normal, mth, DBG, false) unless s1.key?(:normal)
  return hashkey("s2", s2, :normal, mth, DBG, false) unless s2.key?(:normal)
  return hashkey("s1", s1,  :polar, mth, DBG, false) unless s1.key?(:polar)
  return hashkey("s2", s2,  :polar, mth, DBG, false) unless s2.key?(:polar)

  valid1 = s1[:angle].is_a?(Numeric)
  valid2 = s2[:angle].is_a?(Numeric)
  return mismatch("s1 angle", s1[:angle], Numeric, DBG, false) unless valid1
  return mismatch("s1 angle", s1[:angle], Numeric, DBG, false) unless valid2

  angle = 0
  angle = s2[:angle] - s1[:angle] if s2[:angle] > s1[:angle]
  angle = s1[:angle] - s2[:angle] if s1[:angle] > s2[:angle]
  return false if angle < TOL
  return false unless (2 * Math::PI - angle).abs > TOL
  return false if angle > 3 * Math::PI / 4 && angle < 5 * Math::PI / 4

  n1_d_p2 = s1[:normal].dot(s2[:polar])
  p1_d_n2 = s1[:polar].dot(s2[:normal])
  return true if n1_d_p2 > 0 && p1_d_n2 > 0

  false
end

#convex?(s1 = nil, s2 = nil) ⇒ Bool, false

Validates whether edge surfaces form a convex angle, as seen from outside.

Parameters:

  • s1 (Hash) (defaults to: nil)

    first TBD surface

  • s2 (Hash) (defaults to: nil)

    second TBD surface

Options Hash (s1):

  • :normal (Topolys::Vector3D)

    surface normal vector

  • :polar (Topolys::Vector3D)

    vector around edge

  • :angle (Numeric)

    polar angle vs reference (e.g. North, Zenith)

Returns:

  • (Bool)

    true if angle between surfaces is convex

  • (false)

    if invalid input (see logs)



665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
# File 'lib/tbd/geo.rb', line 665

def convex?(s1 = nil, s2 = nil)
  mth = "TBD::#{__callee__}"
  return mismatch("s1", s1, Hash, mth, DBG, false) unless s1.is_a?(Hash)
  return mismatch("s2", s2, Hash, mth, DBG, false) unless s2.is_a?(Hash)
  return false if s1 == s2

  return hashkey("s1", s1,  :angle, mth, DBG, false) unless s1.key?(:angle)
  return hashkey("s2", s2,  :angle, mth, DBG, false) unless s2.key?(:angle)
  return hashkey("s1", s1, :normal, mth, DBG, false) unless s1.key?(:normal)
  return hashkey("s2", s2, :normal, mth, DBG, false) unless s2.key?(:normal)
  return hashkey("s1", s1,  :polar, mth, DBG, false) unless s1.key?(:polar)
  return hashkey("s2", s2,  :polar, mth, DBG, false) unless s2.key?(:polar)

  valid1 = s1[:angle].is_a?(Numeric)
  valid2 = s2[:angle].is_a?(Numeric)
  return mismatch("s1 angle", s1[:angle], Numeric, DBG, false) unless valid1
  return mismatch("s1 angle", s1[:angle], Numeric, DBG, false) unless valid2

  angle = 0
  angle = s2[:angle] - s1[:angle] if s2[:angle] > s1[:angle]
  angle = s1[:angle] - s2[:angle] if s1[:angle] > s2[:angle]
  return false if angle < TOL
  return false unless (2 * Math::PI - angle).abs > TOL
  return false if angle > 3 * Math::PI / 4 && angle < 5 * Math::PI / 4

  n1_d_p2 = s1[:normal].dot(s2[:polar])
  p1_d_n2 = s1[:polar].dot(s2[:normal])
  return true if n1_d_p2 < 0 && p1_d_n2 < 0

  false
end

#dads(model = nil, pops = {}) ⇒ Hash

Adds a collection of bases surfaces (‘dads’) to a Topolys model, including vertices, wires, holes & faces. Also populates the model with sub surfaces (‘kids’).

Parameters:

  • model (Topolys::Model) (defaults to: nil)

    a model

  • pops (Hash) (defaults to: {})

    base surfaces

Options Hash (pops):

  • :points (OpenStudio::Point3dVector)

    base surface 3D points

  • :windows (Hash)

    incorporated windows (see kids)

  • :doors (Hash)

    incorporated doors (see kids)

  • :skylights (Hash)

    incorporated skylights (see kids)

  • :n (OpenStudio::Vector3D)

    outward normal

Returns:

  • (Hash)

    3D Topolys wires of ‘holes’ (made by kids)



168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
# File 'lib/tbd/geo.rb', line 168

def dads(model = nil, pops = {})
  mth   = "TBD::#{__callee__}"
  cl1   = Topolys::Model
  cl2   = Hash
  holes = {}
  return mismatch("model", model, cl2, mth, DBG, {}) unless model.is_a?(cl1)
  return mismatch("pops",   pops, cl2, mth, DBG, {}) unless pops.is_a?(cl2)

  pops.each do |id, props|
    hols   = []
    hinged = []
    obj    = objects(model, props[:points])
    next unless obj[:vx] && obj[:w]

    hols += kids(model, props[:windows  ]) if props.key?(:windows)
    hols += kids(model, props[:doors    ]) if props.key?(:doors)
    hols += kids(model, props[:skylights]) if props.key?(:skylights)

    hols.each { |hol| hinged << hol unless hol.attributes[:unhinged] }

    face = model.get_face(obj[:w], hinged)
    msg  = "Unable to retrieve valid 'dad' (#{mth})"
    log(DBG, msg) unless face
    next          unless face

    face.attributes[:id] = id
    face.attributes[:n ] = props[:n] if props.key?(:n)

    props[:face] = face

    hols.each { |hol| holes[hol.attributes[:id]] = hol }
  end

  holes
end

#derate(id = "", s = {}, lc = nil) ⇒ OpenStudio::Model::Material?

Thermally derates insulating material within construction.

Parameters:

  • id (#to_s) (defaults to: "")

    surface identifier

  • s (Hash) (defaults to: {})

    TBD surface parameters

  • lc (OpenStudio::Model::LayeredConstruction) (defaults to: nil)

    a layered construction

Options Hash (s):

  • :heatloss (#to_f)

    heat loss from major thermal bridging, in W/K

  • :net (#to_f)

    surface net area, in m2

  • :ltype (:massless, :standard)

    indexed layer type

  • :index (#to_i)

    deratable construction layer index

  • :r (#to_f)

    deratable layer Rsi-factor, in m2•K/W

Returns:

  • (OpenStudio::Model::Material)

    derated (cloned) material

  • (nil)

    if invalid input (see logs)



1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
# File 'lib/tbd/psi.rb', line 1369

def derate(id = "", s = {}, lc = nil)
  mth = "TBD::#{__callee__}"
  m   = nil
  id  = trim(id)
  kys = [:heatloss, :net, :ltype, :index, :r]
  ck1 = s.is_a?(Hash)
  ck2 = lc.is_a?(OpenStudio::Model::LayeredConstruction)
  return mismatch("id"                , id, cl6, mth)     if id.empty?
  return mismatch("#{id} surface"     , s , cl1, mth) unless ck1
  return mismatch("#{id} construction", lc, cl2, mth) unless ck2

  kys.each do |k|
    tag = "#{id} #{k}"
    return hashkey(tag, s, k, mth, ERR) unless s.key?(k)

    case k
    when :heatloss
      return mismatch(tag, s[k], Numeric, mth) unless s[k].respond_to?(:to_f)
      return zero(tag, mth, WRN)                   if s[k].to_f.abs < 0.001
    when :net, :r
      return mismatch(tag, s[k], Numeric, mth) unless s[k].respond_to?(:to_f)
      return negative(tag, mth, 2, ERR)            if s[k].to_f < 0
      return zero(tag, mth, WRN)                   if s[k].to_f.abs < 0.001
    when :index
      return mismatch(tag, s[k], Numeric, mth) unless s[k].respond_to?(:to_i)
      return negative(tag, mth, 2, ERR)            if s[k].to_f < 0
    else # :ltype
      next if [:massless, :standard].include?(s[k])
      return invalid(tag, mth, 2, ERR)
    end
  end

  if lc.nameString.downcase.include?(" tbd")
    log(WRN, "Won't derate '#{id}': tagged as derated (#{mth})")
    return m
  end

  model = lc.model
  ltype = s[:ltype   ]
  index = s[:index   ].to_i
  net   = s[:net     ].to_f
  r     = s[:r       ].to_f
  u     = s[:heatloss].to_f / net
  loss  = 0
  de_u  = 1 / r + u # derated U
  de_r  = 1 / de_u  # derated R

  if ltype == :massless
    m    = lc.getLayer(index).to_MasslessOpaqueMaterial
    return invalid("#{id} massless layer?", mth, 0) if m.empty?
    m    = m.get
    up   = ""
    up   = "uprated " if m.nameString.downcase.include?(" uprated")
    m    = m.clone(model).to_MasslessOpaqueMaterial.get
           m.setName("#{id} #{up}m tbd")
    de_r = 0.001                   unless de_r > 0.001
    loss = (de_u - 1 / de_r) * net unless de_r > 0.001
           m.setThermalResistance(de_r)
  else
    m    = lc.getLayer(index).to_StandardOpaqueMaterial
    return invalid("#{id} standard layer?", mth, 0) if m.empty?
    m    = m.get
    up   = ""
    up   = "uprated " if m.nameString.downcase.include?(" uprated")
    m    = m.clone(model).to_StandardOpaqueMaterial.get
           m.setName("#{id} #{up}m tbd")
    k    = m.thermalConductivity

    if de_r > 0.001
      d  = de_r * k

      unless d > 0.003
        d    = 0.003
        k    = d / de_r
        k    = 3                    unless k < 3
        loss = (de_u - k / d) * net unless k < 3
      end
    else # de_r < 0.001 m2•K/W
      d    = 0.001 * k
      d    = 0.003                  unless d > 0.003
      k    = d / 0.001              unless d > 0.003
      loss = (de_u - k / d) * net
    end

    m.setThickness(d)
    m.setThermalConductivity(k)
  end

  if m && loss > TOL
    s[:r_heatloss] = loss
    hl = format "%.3f", s[:r_heatloss]
    log(WRN, "Won't assign #{hl} W/K to '#{id}': too conductive (#{mth})")
  end

  m
end

#exit(runner = nil, argh = {}) ⇒ Bool

Exits TBD Measures. Writes out TBD model content and results if requested. Always writes out minimal logs (see “tbd.out.json” file).

Parameters:

  • runner (Runner) (defaults to: nil)

    OpenStudio Measure runner

  • argh (Hash) (defaults to: {})

    TBD arguments

Options Hash (argh):

  • :io (Hash)

    TBD input/output variables (see TBD JSON schema)

  • :surfaces (Hash)

    TBD surfaces (keys: Openstudio surface names)

  • :seed (#to_s)

    OpenStudio file, e.g. “school23.osm”

  • :version (#to_s)

    :version OpenStudio SDK, e.g. “3.6.1”

  • :gen_ua (Bool)

    whether to generate a UA’ report

  • :ua_ref (#to_s)

    selected UA’ ruleset

  • :setpoints (Bool)

    whether OpenStudio model holds setpoints

  • :write_tbd (Bool)

    whether to output a JSON file

  • :uprate_walls (Bool)

    whether to uprate walls

  • :uprate_roofs (Bool)

    whether to uprate roofs

  • :uprate_floors (Bool)

    whether to uprate floors

  • :wall_ut (#to_f)

    uprated wall Ut target in W/m2•K

  • :roof_ut (#to_f)

    uprated roof Ut target in W/m2•K

  • :floor_ut (#to_f)

    uprated floor Ut target in W/m2•K

  • :wall_option (#to_s)

    wall construction to uprate (or “all”)

  • :roof_option (#to_s)

    roof construction to uprate (or “all”)

  • :floor_option (#to_s)

    floor construction to uprate (or “all”)

  • :wall_uo (#to_f)

    required wall Uo to achieve Ut in W/m2•K

  • :roof_uo (#to_f)

    required roof Uo to achieve Ut in W/m2•K

  • :floor_uo (#to_f)

    required floor Uo to achieve Ut in W/m2•K

Returns:

  • (Bool)

    whether TBD Measure is successful (see logs)



3115
3116
3117
3118
3119
3120
3121
3122
3123
3124
3125
3126
3127
3128
3129
3130
3131
3132
3133
3134
3135
3136
3137
3138
3139
3140
3141
3142
3143
3144
3145
3146
3147
3148
3149
3150
3151
3152
3153
3154
3155
3156
3157
3158
3159
3160
3161
3162
3163
3164
3165
3166
3167
3168
3169
3170
3171
3172
3173
3174
3175
3176
3177
3178
3179
3180
3181
3182
3183
3184
3185
3186
3187
3188
3189
3190
3191
3192
3193
3194
3195
3196
3197
3198
3199
3200
3201
3202
3203
3204
3205
3206
3207
3208
3209
3210
3211
3212
3213
3214
3215
3216
3217
3218
3219
3220
3221
3222
3223
3224
3225
3226
3227
3228
3229
3230
3231
3232
3233
3234
3235
3236
3237
3238
3239
3240
3241
3242
3243
3244
3245
3246
3247
3248
3249
3250
3251
3252
3253
3254
3255
3256
3257
3258
3259
3260
3261
3262
3263
3264
3265
3266
3267
3268
3269
3270
3271
3272
3273
3274
3275
3276
3277
3278
3279
3280
3281
3282
3283
3284
3285
3286
3287
3288
3289
3290
3291
3292
3293
3294
3295
3296
3297
3298
3299
3300
3301
3302
3303
3304
3305
3306
3307
3308
3309
3310
3311
3312
3313
3314
3315
3316
3317
3318
3319
3320
3321
3322
3323
3324
3325
3326
3327
3328
3329
3330
3331
3332
3333
3334
3335
3336
3337
3338
3339
3340
3341
3342
3343
3344
3345
3346
# File 'lib/tbd/psi.rb', line 3115

def exit(runner = nil, argh = {})
  # Generated files target a design context ( >= WARN ) ... change TBD log
  # level for debugging purposes. By default, log status is set < DBG
  # while log level is set @INF.
  groups = { wall: {}, roof: {}, floor: {} }
  state  = msg(status)
  state  = msg(INF)         if status.zero?
  argh            = {}  unless argh.is_a?(Hash)
  argh[:io      ] = nil unless argh.key?(:io)
  argh[:surfaces] = nil unless argh.key?(:surfaces)

  unless argh[:io] && argh[:surfaces]
    state = "Halting all TBD processes, yet running OpenStudio"
    state = "Halting all TBD processes, and halting OpenStudio" if fatal?
  end

  argh[:io           ] = {}    unless argh[:io]
  argh[:seed         ] = ""    unless argh.key?(:seed         )
  argh[:version      ] = ""    unless argh.key?(:version      )
  argh[:gen_ua       ] = false unless argh.key?(:gen_ua       )
  argh[:ua_ref       ] = ""    unless argh.key?(:ua_ref       )
  argh[:setpoints    ] = false unless argh.key?(:setpoints    )
  argh[:write_tbd    ] = false unless argh.key?(:write_tbd    )
  argh[:uprate_walls ] = false unless argh.key?(:uprate_walls )
  argh[:uprate_roofs ] = false unless argh.key?(:uprate_roofs )
  argh[:uprate_floors] = false unless argh.key?(:uprate_floors)
  argh[:wall_ut      ] = 5.678 unless argh.key?(:wall_ut      )
  argh[:roof_ut      ] = 5.678 unless argh.key?(:roof_ut      )
  argh[:floor_ut     ] = 5.678 unless argh.key?(:floor_ut     )
  argh[:wall_option  ] = ""    unless argh.key?(:wall_option  )
  argh[:roof_option  ] = ""    unless argh.key?(:roof_option  )
  argh[:floor_option ] = ""    unless argh.key?(:floor_option )
  argh[:wall_uo      ] = nil   unless argh.key?(:wall_ut      )
  argh[:roof_uo      ] = nil   unless argh.key?(:roof_ut      )
  argh[:floor_uo     ] = nil   unless argh.key?(:floor_ut     )

  groups[:wall ][:up] = argh[:uprate_walls ]
  groups[:roof ][:up] = argh[:uprate_roofs ]
  groups[:floor][:up] = argh[:uprate_floors]
  groups[:wall ][:ut] = argh[:wall_ut      ]
  groups[:roof ][:ut] = argh[:roof_ut      ]
  groups[:floor][:ut] = argh[:floor_ut     ]
  groups[:wall ][:op] = argh[:wall_option  ]
  groups[:roof ][:op] = argh[:roof_option  ]
  groups[:floor][:op] = argh[:floor_option ]
  groups[:wall ][:uo] = argh[:wall_uo      ]
  groups[:roof ][:uo] = argh[:roof_uo      ]
  groups[:floor][:uo] = argh[:floor_uo     ]

  io               = argh[:io       ]
  out              = argh[:write_tbd]
  descr            = ""
  descr            = argh[:seed] unless argh[:seed].empty?
  io[:description] = descr       unless io.key?(:description)
  descr            = io[:description]

  schema_pth  = "https://github.com/rd2/tbd/blob/master/tbd.schema.json"
  io[:schema] = schema_pth unless io.key?(:schema)
  tbd_log     = { date: Time.now, status: state }
  u_t         = []

  groups.each do |label, g|
    next     if fatal?
    next unless g[:uo]
    next unless g[:uo].is_a?(Numeric)

    uo     = format("%.3f", g[:uo])
    ut     = format("%.3f", g[:ut])
    output = "An initial #{label.to_s} Uo of #{uo} W/m2•K is required to " \
             "achieve an overall Ut of #{ut} W/m2•K for #{g[:op]}"
    u_t << output
    runner.registerInfo(output)
  end

  tbd_log[:ut] = u_t unless u_t.empty?
  ua_md_en     = nil
  ua_md_fr     = nil
  ua           = nil
  ok           = argh[:surfaces] && argh[:gen_ua]
  ua           = ua_summary(tbd_log[:date], argh) if ok

  unless fatal? || ua.nil? || ua.empty?
    if ua.key?(:en)
      if ua[:en].key?(:b1) || ua[:en].key?(:b2)
        tbd_log[:ua] = {}
        runner.registerInfo("-")
        runner.registerInfo(ua[:model])
        ua_md_en = ua_md(ua, :en)
        ua_md_fr = ua_md(ua, :fr)
      end

      if ua[:en].key?(:b1) && ua[:en][:b1].key?(:summary)
        runner.registerInfo(" - #{ua[:en][:b1][:summary]}")

        ua[:en][:b1].each do |k, v|
          runner.registerInfo(" --- #{v}") unless k == :summary
        end

        tbd_log[:ua][:bloc1] = ua[:en][:b1]
      end

      if ua[:en].key?(:b2) && ua[:en][:b2].key?(:summary)
        runner.registerInfo(" - #{ua[:en][:b2][:summary]}")

        ua[:en][:b2].each do |k, v|
          runner.registerInfo(" --- #{v}") unless k == :summary
        end

        tbd_log[:ua][:bloc2] = ua[:en][:b2]
      end
    end

    runner.registerInfo(" -")
  end

  results = []

  if argh[:surfaces]
    argh[:surfaces].each do |id, surface|
      next     if fatal?
      next unless surface.key?(:ratio)

      ratio  = format("%4.1f", surface[:ratio])
      output = "RSi derated by #{ratio}% : #{id}"
      results << output
      runner.registerInfo(output)
    end
  end

  tbd_log[:results] = results unless results.empty?
  tbd_msgs = []

  logs.each do |l|
    tbd_msgs << { level: tag(l[:level]), message: l[:message] }

    runner.registerWarning(l[:message]) if l[:level] >  INF
    runner.registerInfo(l[:message])    if l[:level] <= INF
  end

  tbd_log[:messages] = tbd_msgs unless tbd_msgs.empty?
  io[:log]           = tbd_log

  # User's may not be requesting detailed output - delete non-essential items.
  io.delete(:psis      ) unless out
  io.delete(:khis      ) unless out
  io.delete(:building  ) unless out
  io.delete(:stories   ) unless out
  io.delete(:spacetypes) unless out
  io.delete(:spaces    ) unless out
  io.delete(:surfaces  ) unless out
  io.delete(:edges     ) unless out

  # Deterministic sorting
  io[:schema     ] = io.delete(:schema     ) if io.key?(:schema     )
  io[:description] = io.delete(:description) if io.key?(:description)
  io[:log        ] = io.delete(:log        ) if io.key?(:log        )
  io[:psis       ] = io.delete(:psis       ) if io.key?(:psis       )
  io[:khis       ] = io.delete(:khis       ) if io.key?(:khis       )
  io[:building   ] = io.delete(:building   ) if io.key?(:building   )
  io[:stories    ] = io.delete(:stories    ) if io.key?(:stories    )
  io[:spacetypes ] = io.delete(:spacetypes ) if io.key?(:spacetypes )
  io[:spaces     ] = io.delete(:spaces     ) if io.key?(:spaces     )
  io[:surfaces   ] = io.delete(:surfaces   ) if io.key?(:surfaces   )
  io[:edges      ] = io.delete(:edges      ) if io.key?(:edges      )

  out_dir = '.'
  file_paths = runner.workflow.absoluteFilePaths

  # 'Apply Measure Now' won't cp files from 1st path back to generated_files.
  match1 = /WorkingFiles/.match(file_paths[1].to_s.strip)
  match2 = /files/.match(file_paths[1].to_s.strip)
  match  = match1 || match2

  if file_paths.size >= 2 && File.exist?(file_paths[1].to_s.strip) && match
    out_dir = file_paths[1].to_s.strip
  elsif !file_paths.empty? && File.exist?(file_paths.first.to_s.strip)
    out_dir = file_paths.first.to_s.strip
  end

  out_path = File.join(out_dir, "tbd.out.json")

  File.open(out_path, 'w') do |file|
    file.puts JSON::pretty_generate(io)
    # Make sure data is written to the disk one way or the other.
    begin
      file.fsync
    rescue StandardError
      file.flush
    end
  end

  unless fatal? || ua.nil? || ua.empty?
    unless ua_md_en.nil? || ua_md_en.empty?
      ua_path = File.join(out_dir, "ua_en.md")

      File.open(ua_path, 'w') do |file|
        file.puts ua_md_en

        begin
          file.fsync
        rescue StandardError
          file.flush
        end
      end
    end

    unless ua_md_fr.nil? || ua_md_fr.empty?
      ua_path = File.join(out_dir, "ua_fr.md")

      File.open(ua_path, 'w') do |file|
        file.puts ua_md_fr

        begin
          file.fsync
        rescue StandardError
          file.flush
        end
      end
    end
  end

  if fatal?
    runner.registerError("#{state} - see 'tbd.out.json'")
    return false
  elsif error? || warn?
    runner.registerWarning("#{state} - see 'tbd.out.json'")
    return true
  else
    runner.registerInfo("#{state} - see 'tbd.out.json'")
    return true
  end
end

#faces(s = {}, e = {}) ⇒ Bool, false

Populates TBD edges with linked Topolys faces.

Parameters:

  • s (Hash) (defaults to: {})

    TBD surfaces

  • e (Hash) (defaults to: {})

    TBD edges

Options Hash (s):

  • :face (Topolys::Face)

    a Topolys face

Options Hash (e):

  • :length (Numeric)

    edge length

  • :v0 (Topolys::Vertex)

    edge origin vertex

  • :v1 (Topolys::Vertex)

    edge terminal vertex

Returns:

  • (Bool)

    whether successful in populating faces

  • (false)

    if invalid input (see logs)



216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
# File 'lib/tbd/geo.rb', line 216

def faces(s = {}, e = {})
  mth = "TBD::#{__callee__}"
  return mismatch("surfaces", s, Hash, mth, DBG, false) unless s.is_a?(Hash)
  return mismatch("edges",    e, Hash, mth, DBG, false) unless e.is_a?(Hash)

  s.each do |id, props|
    unless props.key?(:face)
      log(DBG, "Missing Topolys face '#{id}' (#{mth})")
      next
    end

    props[:face].wires.each do |wire|
      wire.edges.each do |edge|
        unless e.key?(edge.id)
          e[edge.id] = { length: edge.length,
                             v0: edge.v0,
                             v1: edge.v1,
                       surfaces: {} }
        end

        unless e[edge.id][:surfaces].key?(id)
          e[edge.id][:surfaces][id] = { wire: wire.id }
        end
      end
    end
  end

  true
end

#inputs(s = {}, e = {}, argh = {}) ⇒ Hash

Processes TBD JSON inputs, after TBD has preprocessed OpenStudio model variables and retrieved corresponding Topolys model surface/edge properties. TBD user inputs allow customization of default assumptions and inferred values. If successful, “edges” (input) may inherit additional properties, e.g.: edge-specific PSI set (defined in TBD JSON file), edge-specific PSI type (e.g. “corner”, defined in TBD JSON file), project-wide PSI set (if absent from TBD JSON file).

Parameters:

  • s (Hash) (defaults to: {})

    TBD surfaces (keys: Openstudio surface names)

  • e (Hash) (defaults to: {})

    TBD edges (keys: Topolys edge identifiers)

  • argh (Hash) (defaults to: {})

    TBD arguments

Options Hash (s):

  • :windows (Hash)

    TBD surface-specific windows e.g. s[]

  • :doors (Hash)

    TBD surface-specific doors

  • :skylights (Hash)

    TBD surface-specific skylights

  • :story (OpenStudio::Model::BuildingStory)

    OpenStudio story

  • :stype ("Wall", "RoofCeiling", "Floor")

    OpenStudio surface type

  • :space (OpenStudio::Model::Space)

    OpenStudio space

Options Hash (e):

  • :surfaces (Hash)

    linked TBD surfaces e.g. e[]

  • :length (#to_f)

    edge length in m

  • :v0 (Topolys::Point3D)

    origin vertex

  • :v1 (Topolys::Point3D)

    terminal vertex

Options Hash (argh):

  • :option (#to_s)

    selected PSI set

  • :io_path (#to_s)

    tbd.json input file path

  • :schema_path (#to_s)

    TBD JSON schema file path

Returns:

  • (Hash)

    io: (Hash), psi:/khi: enriched sets (see logs if empty)



1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
# File 'lib/tbd/psi.rb', line 1124

def inputs(s = {}, e = {}, argh = {})
  mth = "TBD::#{__callee__}"
  opt = :option
  ipt = { io: {}, psi: PSI.new, khi: KHI.new }
  io  = {}
  return mismatch("s"   , s   , Hash, mth, DBG, ipt) unless s.is_a?(Hash)
  return mismatch("e"   , e   , Hash, mth, DBG, ipt) unless e.is_a?(Hash)
  return mismatch("argh", argh, Hash, mth, DBG, ipt) unless argh.is_a?(Hash)
  return hashkey("argh" , argh, opt , mth, DBG, ipt) unless argh.key?(opt)

  argh[:io_path    ] = nil unless argh.key?(:io_path)
  argh[:schema_path] = nil unless argh.key?(:schema_path)

  pth = argh[:io_path    ]
  sch = argh[:schema_path]

  if pth && (pth.is_a?(String) || pth.is_a?(Hash))
    if pth.is_a?(Hash)
      io = pth
    else
      return empty("JSON file", mth, FTL, ipt) unless File.size?(pth)

      io = File.read(pth)
      io = JSON.parse(io, symbolize_names: true)
      return mismatch("io", io, Hash, mth, FTL, ipt) unless io.is_a?(Hash)
    end

    # Schema validation is not yet supported in the OpenStudio Application.
    # It is nonetheless recommended that users rely on the json-schema gem,
    # or an online linter, prior to using TBD. The following checks focus on
    # content - ignoring bad JSON input otherwise caught via JSON validation.
    #
    # A side note: JSON validation relies on case-senitive string comparisons
    # (e.g. OpenStudio space or surface names, vs corresponding TBD JSON
    # identifiers). So "Space-1" doesn't match "SPACE-1" ... head's up!
    if sch
      require "json-schema"
      return invalid("JSON schema", mth, 3, FTL, ipt) unless File.exist?(sch)
      return empty("JSON schema"  , mth,    FTL, ipt)     if File.zero?(sch)

      schema = File.read(sch)
      schema = JSON.parse(schema, symbolize_names: true)
      valid  = JSON::Validator.validate!(schema, io)
      return invalid("JSON schema validation", mth, 3, FTL, ipt) unless valid
    end

    # Append JSON entries to library of linear & point thermal bridges.
    io[:psis].each { |psi| ipt[:psi].append(psi) } if io.key?(:psis)
    io[:khis].each { |khi| ipt[:khi].append(khi) } if io.key?(:khis)

    # JSON-defined or user-selected, building PSI set must be complete/valid.
    io[:building] = { psi: argh[opt] } unless io.key?(:building)
    bdg = io[:building]
    ok  = bdg.key?(:psi)
    return hashkey("Building PSI", bdg, :psi, mth, FTL, ipt)  unless ok

    ok = ipt[:psi].complete?(bdg[:psi])
    return invalid("Complete building PSI", mth, 3, FTL, ipt) unless ok

    # Validate remaining (optional) JSON entries.
    [:stories, :spacetypes, :spaces].each do |types|
      key = :story
      key = :stype if types == :spacetypes
      key = :space if types == :spaces

      if io.key?(types)
        io[types].each do |type|
          next unless type.key?(:psi)
          next unless type.key?(:id )

          s1 = "JSON/OSM '#{type[:id]}' (#{mth})"
          s2 = "JSON/PSI '#{type[:id]}' set (#{mth})"
          match = false

          s.values.each do |props| # TBD surface linked to type?
            break    if match
            next unless props.key?(key)

            match = type[:id] == props[key].nameString
          end

          log(ERR, s1) unless match
          log(ERR, s2) unless ipt[:psi].set.key?(type[:psi])
        end
      end
    end

    if io.key?(:surfaces)
      io[:surfaces].each do |surface|
        next unless surface.key?(:id)

        s1 = "JSON/OSM surface '#{surface[:id]}' (#{mth})"
        log(ERR, s1) unless s.key?(surface[:id])

        # surfaces can OPTIONALLY hold custom PSI sets and/or KHI data
        if surface.key?(:psi)
          s2 = "JSON/OSM surface/set '#{surface[:id]}' (#{mth})"
          log(ERR, s2) unless ipt[:psi].set.key?(surface[:psi])
        end

        if surface.key?(:khis)
          surface[:khis].each do |khi|
            next unless khi.key?(:id)

            s3 = "JSON/KHI surface '#{surface[:id]}' '#{khi[:id]}' (#{mth})"
            log(ERR, s3) unless ipt[:khi].point.key?(khi[:id])
          end
        end
      end
    end

    if io.key?(:subsurfaces)
      io[:subsurfaces].each do |sub|
        next unless sub.key?(:id)
        next unless sub.key?(:usi)

        match = false

        s.each do |id, surface|
          break if match

          [:windows, :doors, :skylights].each do |holes|
            if surface.key?(holes)
              surface[holes].keys.each do |id|
                break if match

                match = sub[:id] == id
              end
            end
          end
        end

        log(ERR, "JSON/OSM subsurface '#{sub[:id]}' (#{mth})") unless match
      end
    end

    if io.key?(:edges)
      io[:edges].each do |edge|
        next unless edge.key?(:type)
        next unless edge.key?(:surfaces)

        surfaces = edge[:surfaces]
        type     = edge[:type].to_sym
        safer    = ipt[:psi].safe(bdg[:psi], type) # fallback
        log(ERR, "Skipping invalid edge PSI '#{type}' (#{mth})") unless safer
        next unless safer

        valid = true

        surfaces.each do |surface|         #   TBD edge's surfaces on file
          e.values.each do |ee|            #           TBD edges in memory
            break unless valid             #  if previous anomaly detected
            next      if ee.key?(:io_type) #  validated from previous loop
            next  unless ee.key?(:surfaces)

            surfs      = ee[:surfaces]
            next  unless surfs.key?(surface)

            # An edge on file is valid if ALL of its listed surfaces together
            # connect at least 1 or more TBD/Topolys model edges in memory.
            # Each of the latter may connect e.g. 3 TBD/Topolys surfaces,
            # but the list of surfaces on file may be shorter, e.g. only 2.
            match = true
            surfaces.each { |id| match = false unless surfs.key?(id) }
            next unless match

            if edge.key?(:length) # optional
              next unless (ee[:length] - edge[:length]).abs < TOL
            end

            # Optionally, edge coordinates may narrow down potential matches.
            if edge.key?(:v0x) || edge.key?(:v0y) || edge.key?(:v0z) ||
               edge.key?(:v1x) || edge.key?(:v1y) || edge.key?(:v1z)

              unless edge.key?(:v0x) && edge.key?(:v0y) && edge.key?(:v0z) &&
                     edge.key?(:v1x) && edge.key?(:v1y) && edge.key?(:v1z)
                log(ERR, "Mismatch '#{surface}' edge vertices (#{mth})")
                valid = false
                next
              end

              e1 = {}
              e2 = {}
              e1[:v0] = Topolys::Point3D.new(edge[:v0x].to_f,
                                             edge[:v0y].to_f,
                                             edge[:v0z].to_f)
              e1[:v1] = Topolys::Point3D.new(edge[:v1x].to_f,
                                             edge[:v1y].to_f,
                                             edge[:v1z].to_f)
              e2[:v0] = ee[:v0].point
              e2[:v1] = ee[:v1].point
              next unless matches?(e1, e2)
            end

            if edge.key?(:psi) # optional
              set = edge[:psi]

              if ipt[:psi].set.key?(set)
                saferr       = ipt[:psi].safe(set, type)
                ee[:io_set ] = set                               if saferr
                ee[:io_type] = type                              if saferr
                log(ERR, "Invalid #{set}: #{type} (#{mth})") unless saferr
                valid = false                                unless saferr
              else
                log(ERR, "Missing edge PSI #{set} (#{mth})")
                valid = false
              end
            else
              ee[:io_type] = type # success: matching edge - setting edge type
            end
          end
        end
      end
    end
  else
    # No (optional) user-defined TBD JSON input file. In such cases, provided
    # argh[:option] must refer to a valid PSI set. If valid, all edges inherit
    # a default PSI set (without KHI entries).
    msg = "Incomplete building PSI set '#{argh[opt]}' (#{mth})"
    ok  = ipt[:psi].complete?(argh[opt])

    io[:building] = { psi: argh[opt] } if ok
    log(FTL, msg)                  unless ok
    return ipt                     unless ok
  end

  ipt[:io] = io

  ipt
end

#kids(model = nil, boys = {}) ⇒ Array<Topolys::Wire>

Adds a collection of TBD sub surfaces (‘kids’) to a Topolys model, including vertices, wires & holes. A sub surface is typically ‘hinged’, i.e. along the same 3D plane as its base surface (or ‘dad’). In rare cases such as domes of tubular daylighting devices (TDDs), a sub surface may be ‘unhinged’.

Parameters:

  • model (Topolys::Model) (defaults to: nil)

    a model

  • boys (Hash) (defaults to: {})

    a collection of TBD subsurfaces

Options Hash (boys):

  • :points (Array<Topolys::Point3D>)

    sub surface 3D points

  • :unhinged (Bool)

    whether same 3D plane as base surface

  • :n (OpenStudio::Vector3d)

    outward normal

Returns:

  • (Array<Topolys::Wire>)

    holes cut out by kids (see logs if empty)



131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
# File 'lib/tbd/geo.rb', line 131

def kids(model = nil, boys = {})
  mth   = "TBD::#{__callee__}"
  cl1   = Topolys::Model
  cl2   = Hash
  holes = []
  return mismatch("model", model, cl1, mth, DBG, {}) unless model.is_a?(cl1)
  return mismatch("boys",   boys, cl2, mth, DBG, {}) unless boys.is_a?(cl2)

  boys.each do |id, props|
    obj = objects(model, props[:points])
    next unless obj[:w]

    obj[:w].attributes[:id      ] = id
    obj[:w].attributes[:unhinged] = props[:unhinged] if props.key?(:unhinged)
    obj[:w].attributes[:n       ] = props[:n       ] if props.key?(:n)

    props[:hole] = obj[:w]
    holes << obj[:w]
  end

  holes
end

#kiva(model = nil, walls = {}, floors = {}, edges = {}) ⇒ Bool, false

Generates Kiva settings and objects if model surfaces have ‘foundation’ boundary conditions.

Parameters:

  • model (OpenStudio::Model::Model) (defaults to: nil)

    a model

  • floors (Hash) (defaults to: {})

    TBD floors

  • walls (Hash) (defaults to: {})

    TBD walls

  • edges (Hash) (defaults to: {})

    TBD edges (many linking floors & walls

Returns:

  • (Bool)

    true if Kiva foundations are successfully generated

  • (false)

    if invalid input (see logs)



765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
# File 'lib/tbd/geo.rb', line 765

def kiva(model = nil, walls = {}, floors = {}, edges = {})
  mth = "TBD::#{__callee__}"
  cl1 = OpenStudio::Model::Model
  cl2 = Hash
  a   = false
  return mismatch("model" ,  model, cl1, mth, DBG, a) unless model.is_a?(cl1)
  return mismatch("walls" ,  walls, cl2, mth, DBG, a) unless walls.is_a?(cl2)
  return mismatch("floors", floors, cl2, mth, DBG, a) unless floors.is_a?(cl2)
  return mismatch("edges" ,  edges, cl2, mth, DBG, a) unless edges.is_a?(cl2)

  # Check for existing KIVA objects.
  kva = false
  kva = true unless model.getSurfacePropertyExposedFoundationPerimeters.empty?
  kva = true unless model.getFoundationKivas.empty?

  if kva
    log(ERR, "Exiting - KIVA objects in model (#{mth})")
    return a
  else
    kva = true
  end

  # Pre-validate foundation-facing constructions.
  model.getSurfaces.each do |s|
    id = s.nameString
    construction = s.construction
    next unless s.outsideBoundaryCondition.downcase == "foundation"

    if construction.empty?
      log(ERR, "Invalid construction for #{id} (#{mth})")
      kva = false
    else
      construction = construction.get.to_LayeredConstruction

      if construction.empty?
        log(ERR, "Invalid layered constructions for #{id} (#{mth})")
        kva = false
      else
        construction = construction.get

        unless standardOpaqueLayers?(construction)
          log(ERR, "Non-standard materials for #{id} (#{mth})")
          kva = false
        end
      end
    end
  end

  return a unless kva

  # Strictly relying on Kiva's total exposed perimeter approach.
  arg  = "TotalExposedPerimeter"
  kiva = true
  # The following is loosely adapted from:
  #
  #   github.com/NREL/OpenStudio-resources/blob/develop/model/
  #   simulationtests/foundation_kiva.rb ... thanks.
  #
  # Access to KIVA settings. This is usually not required (the default KIVA
  # settings are fine), but its explicit inclusion in the model does offer
  # users easy access to further tweak settings, e.g. soil properties if
  # required. Initial tests show slight differences in simulation results
  # w/w/o explcit inclusion of the KIVA settings template in the model.
  settings = model.getFoundationKivaSettings

  k = settings.soilConductivity
  settings.setSoilConductivity(k)

  # Tag foundation-facing floors, then walls.
  edges.each do |code1, edge|
    edge[:surfaces].keys.each do |id|
      next unless floors.key?(id)
      next unless floors[id][:boundary].downcase == "foundation"
      next     if floors[id].key?(:kiva)

      floors[id][:kiva   ] = :slab # initially slabs-on-grade
      floors[id][:exposed] = 0.0   # slab-on-grade or walkout perimeter

      # Loop around current edge.
      edge[:surfaces].keys.each do |i|
        next     if i == id
        next unless walls.key?(i)
        next unless walls[i][:boundary].downcase == "foundation"
        next     if walls[i].key?(:kiva)

        floors[id][:kiva] = :basement
        walls[i  ][:kiva] = id
      end

      # Loop around current edge.
      edge[:surfaces].keys.each do |i|
        next     if i == id
        next unless walls.key?(i)
        next unless walls[i][:boundary].downcase == "outdoors"

        floors[id][:exposed] += edge[:length]
      end

      # Loop around other floor edges.
      edges.each do |code2, e|
        next if code1 == code2 #  skip - same edge

        e[:surfaces].keys.each do |i|
          next unless i == id # good - same floor

          e[:surfaces].keys.each do |ii|
            next     if i == ii
            next unless walls.key?(ii)
            next unless walls[ii][:boundary].downcase == "foundation"
            next     if walls[ii].key?(:kiva)

            floors[id][:kiva] = :basement
            walls[ii ][:kiva] = id
          end

          e[:surfaces].keys.each do |ii|
            next    if i == ii
            next unless walls.key?(ii)
            next unless walls[ii][:boundary].downcase == "outdoors"

            floors[id][:exposed] += e[:length]
          end
        end
      end

      foundation = OpenStudio::Model::FoundationKiva.new(model)
      foundation.setName("KIVA Foundation Floor #{id}")
      floor = model.getSurfaceByName(id)
      kiva  = false if floor.empty?
      next          if floor.empty?

      floor          = floor.get
      construction   = floor.construction
      kiva = false  if construction.empty?
      next          if construction.empty?

      construction   = construction.get
      floor.setAdjacentFoundation(foundation)
      floor.setConstruction(construction)
      ep   = floors[id][:exposed]
      per  = floor.createSurfacePropertyExposedFoundationPerimeter(arg, ep)
      kiva = false  if per.empty?
      next          if per.empty?

      per            = per.get
      perimeter      = per.totalExposedPerimeter
      kiva = false  if perimeter.empty?
      next          if perimeter.empty?

      perimeter      = perimeter.get

      if ep < 0.001
        ok   = per.setTotalExposedPerimeter(0.000)
        ok   = per.setTotalExposedPerimeter(0.001) unless ok
        kiva = false                               unless ok
      elsif (perimeter - ep).abs < TOL
        xps25 = model.getStandardOpaqueMaterialByName("XPS 25mm")

        if xps25.empty?
          xps25 = OpenStudio::Model::StandardOpaqueMaterial.new(model)
          xps25.setName("XPS 25mm")
          xps25.setRoughness("Rough")
          xps25.setThickness(0.0254)
          xps25.setConductivity(0.029)
          xps25.setDensity(28)
          xps25.setSpecificHeat(1450)
          xps25.setThermalAbsorptance(0.9)
          xps25.setSolarAbsorptance(0.7)
        else
          xps25 = xps25.get
        end

        foundation.setInteriorHorizontalInsulationMaterial(xps25)
        foundation.setInteriorHorizontalInsulationWidth(0.6)
      end

      floors[id][:foundation] = foundation
    end
  end

  walls.each do |i, wall|
    next unless wall.key?(:kiva)

    id = walls[i][:kiva]
    next unless floors.key?(id)
    next unless floors[id].key?(:foundation)

    mur = model.getSurfaceByName(i) # locate OpenStudio wall
    kiva = false if mur.empty?
    next         if mur.empty?

    mur           = mur.get
    construction  = mur.construction
    kiva = false if construction.empty?
    next         if construction.empty?

    construction  = construction.get
    mur.setAdjacentFoundation(floors[id][:foundation])
    mur.setConstruction(construction)
  end

  kiva
end

#matches?(e1 = {}, e2 = {}, tol = TOL) ⇒ Bool, false

Checks whether 2 edges share Topolys vertex pairs.

Parameters:

  • e1 (Hash) (defaults to: {})

    first edge

  • e2 (Hash) (defaults to: {})

    second edge

  • tol (Numeric) (defaults to: TOL)

    tolerance (OSut::TOL) in m

Options Hash (e1):

  • :v0 (Topolys::Point3D)

    origin vertex

  • :v1 (Topolys::Point3D)

    terminal vertex

Returns:

  • (Bool)

    whether edges share vertex pairs

  • (false)

    if invalid input (see logs)



35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
# File 'lib/tbd/geo.rb', line 35

def matches?(e1 = {}, e2 = {}, tol = TOL)
  mth = "TBD::#{__callee__}"
  cl  = Topolys::Point3D
  a   = false
  return mismatch("e1", e1, Hash, mth, DBG, a)       unless e1.is_a?(Hash)
  return mismatch("e2", e2, Hash, mth, DBG, a)       unless e2.is_a?(Hash)
  return mismatch("e2", e2, Hash, mth, DBG, a)       unless e2.is_a?(Hash)

  return hashkey("e1", e1, :v0, mth, DBG, a)         unless e1.key?(:v0)
  return hashkey("e1", e1, :v1, mth, DBG, a)         unless e1.key?(:v1)
  return hashkey("e2", e2, :v0, mth, DBG, a)         unless e2.key?(:v0)
  return hashkey("e2", e2, :v1, mth, DBG, a)         unless e2.key?(:v1)

  return mismatch("e1:v0", e1[:v0], cl, mth, DBG, a) unless e1[:v0].is_a?(cl)
  return mismatch("e1:v1", e1[:v1], cl, mth, DBG, a) unless e1[:v1].is_a?(cl)
  return mismatch("e2:v0", e2[:v0], cl, mth, DBG, a) unless e2[:v0].is_a?(cl)
  return mismatch("e2:v1", e2[:v1], cl, mth, DBG, a) unless e2[:v1].is_a?(cl)

  e1_vector = e1[:v1] - e1[:v0]
  e2_vector = e2[:v1] - e2[:v0]

  return zero("e1", mth, DBG, a) if e1_vector.magnitude < TOL
  return zero("e2", mth, DBG, a) if e2_vector.magnitude < TOL

  return mismatch("e1", e1, Hash, mth, DBG, a) unless tol.is_a?(Numeric)
  return zero("tol", mth, DBG, a)                  if tol < TOL

  return true if
  (
    (
      ( (e1[:v0].x - e2[:v0].x).abs < tol &&
        (e1[:v0].y - e2[:v0].y).abs < tol &&
        (e1[:v0].z - e2[:v0].z).abs < tol
      ) ||
      ( (e1[:v0].x - e2[:v1].x).abs < tol &&
        (e1[:v0].y - e2[:v1].y).abs < tol &&
        (e1[:v0].z - e2[:v1].z).abs < tol
      )
    ) &&
    (
      ( (e1[:v1].x - e2[:v0].x).abs < tol &&
        (e1[:v1].y - e2[:v0].y).abs < tol &&
        (e1[:v1].z - e2[:v0].z).abs < tol
      ) ||
      ( (e1[:v1].x - e2[:v1].x).abs < tol &&
        (e1[:v1].y - e2[:v1].y).abs < tol &&
        (e1[:v1].z - e2[:v1].z).abs < tol
      )
    )
  )

  false
end

#objects(model = nil, pts = []) ⇒ Hash

Returns Topolys vertices and a Topolys wire from Topolys points. If missing, it populates the Topolys model with the vertices and wire.

Parameters:

  • model (Topolys::Model) (defaults to: nil)

    a model

  • pts (Array<Topolys::Point3D>) (defaults to: [])

    3D points

Returns:

  • (Hash)

    vx: (Array<Topolys::Vertex>); w: (Topolys::Wire)

  • (Hash)

    vx: nil, w: nil if invalid input (see logs)



98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
# File 'lib/tbd/geo.rb', line 98

def objects(model = nil, pts = [])
  mth = "TBD::#{__callee__}"
  cl1 = Topolys::Model
  cl2 = Array
  cl3 = Topolys::Point3D
  obj = { vx: nil, w: nil }
  return mismatch("model", model, cl1, mth, DBG, obj) unless model.is_a?(cl1)
  return mismatch("points",  pts, cl2, mth, DBG, obj) unless pts.is_a?(cl2)

  pts.each do |pt|
    return mismatch("point", pt, cl3, mth, DBG, obj) unless pt.is_a?(cl3)
  end

  obj[:vx] = model.get_vertices(pts)
  obj[:w ] = model.get_wire(obj[:vx])

  obj
end

#process(model = nil, argh = {}) ⇒ Hash

Processes TBD objects, based on an OpenStudio and generated Topolys model, and derates admissible envelope surfaces by substituting insulating materials with derated clones, within surface multilayered constructions. Returns a Hash holding 2 key:value pairs; io: objects for JSON serialization, and surfaces: derated TBD surfaces (see exit method).

Parameters:

  • model (OpenStudio::Model::Model) (defaults to: nil)

    a model

  • argh (Hash) (defaults to: {})

    TBD arguments

Options Hash (argh):

  • :option (#to_s)

    selected PSI set

  • :io_path (#to_s)

    tbd.json input file path

  • :schema_path (#to_s)

    TBD JSON schema file path

  • :parapet (Bool) — default: true

    wall-roof edge as parapet

  • :uprate_walls (Bool)

    whether to uprate walls

  • :uprate_roofs (Bool)

    whether to uprate roofs

  • :uprate_floors (Bool)

    whether to uprate floors

  • :wall_ut (Bool)

    uprated wall Ut target in W/m2•K

  • :roof_ut (Bool)

    uprated roof Ut target in W/m2•K

  • :floor_ut (Bool)

    uprated floor Ut target in W/m2•K

  • :wall_option (#to_s)

    wall construction to uprate (or “all”)

  • :roof_option (#to_s)

    roof construction to uprate (or “all”)

  • :floor_option (#to_s)

    floor construction to uprate (or “all”)

  • :gen_ua (Bool)

    whether to generate a UA’ report

  • :ua_ref (#to_s)

    selected UA’ ruleset

  • :gen_kiva (Bool)

    whether to generate KIVA inputs

  • :sub_tol (#to_f)

    proximity tolerance between edges in m

Returns:

  • (Hash)

    io: (Hash), surfaces: (Hash)

  • (Hash)

    io: nil, surfaces: nil if invalid input (see logs)



1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
2164
2165
2166
2167
2168
2169
2170
2171
2172
2173
2174
2175
2176
2177
2178
2179
2180
2181
2182
2183
2184
2185
2186
2187
2188
2189
2190
2191
2192
2193
2194
2195
2196
2197
2198
2199
2200
2201
2202
2203
2204
2205
2206
2207
2208
2209
2210
2211
2212
2213
2214
2215
2216
2217
2218
2219
2220
2221
2222
2223
2224
2225
2226
2227
2228
2229
2230
2231
2232
2233
2234
2235
2236
2237
2238
2239
2240
2241
2242
2243
2244
2245
2246
2247
2248
2249
2250
2251
2252
2253
2254
2255
2256
2257
2258
2259
2260
2261
2262
2263
2264
2265
2266
2267
2268
2269
2270
2271
2272
2273
2274
2275
2276
2277
2278
2279
2280
2281
2282
2283
2284
2285
2286
2287
2288
2289
2290
2291
2292
2293
2294
2295
2296
2297
2298
2299
2300
2301
2302
2303
2304
2305
2306
2307
2308
2309
2310
2311
2312
2313
2314
2315
2316
2317
2318
2319
2320
2321
2322
2323
2324
2325
2326
2327
2328
2329
2330
2331
2332
2333
2334
2335
2336
2337
2338
2339
2340
2341
2342
2343
2344
2345
2346
2347
2348
2349
2350
2351
2352
2353
2354
2355
2356
2357
2358
2359
2360
2361
2362
2363
2364
2365
2366
2367
2368
2369
2370
2371
2372
2373
2374
2375
2376
2377
2378
2379
2380
2381
2382
2383
2384
2385
2386
2387
2388
2389
2390
2391
2392
2393
2394
2395
2396
2397
2398
2399
2400
2401
2402
2403
2404
2405
2406
2407
2408
2409
2410
2411
2412
2413
2414
2415
2416
2417
2418
2419
2420
2421
2422
2423
2424
2425
2426
2427
2428
2429
2430
2431
2432
2433
2434
2435
2436
2437
2438
2439
2440
2441
2442
2443
2444
2445
2446
2447
2448
2449
2450
2451
2452
2453
2454
2455
2456
2457
2458
2459
2460
2461
2462
2463
2464
2465
2466
2467
2468
2469
2470
2471
2472
2473
2474
2475
2476
2477
2478
2479
2480
2481
2482
2483
2484
2485
2486
2487
2488
2489
2490
2491
2492
2493
2494
2495
2496
2497
2498
2499
2500
2501
2502
2503
2504
2505
2506
2507
2508
2509
2510
2511
2512
2513
2514
2515
2516
2517
2518
2519
2520
2521
2522
2523
2524
2525
2526
2527
2528
2529
2530
2531
2532
2533
2534
2535
2536
2537
2538
2539
2540
2541
2542
2543
2544
2545
2546
2547
2548
2549
2550
2551
2552
2553
2554
2555
2556
2557
2558
2559
2560
2561
2562
2563
2564
2565
2566
2567
2568
2569
2570
2571
2572
2573
2574
2575
2576
2577
2578
2579
2580
2581
2582
2583
2584
2585
2586
2587
2588
2589
2590
2591
2592
2593
2594
2595
2596
2597
2598
2599
2600
2601
2602
2603
2604
2605
2606
2607
2608
2609
2610
2611
2612
2613
2614
2615
2616
2617
2618
2619
2620
2621
2622
2623
2624
2625
2626
2627
2628
2629
2630
2631
2632
2633
2634
2635
2636
2637
2638
2639
2640
2641
2642
2643
2644
2645
2646
2647
2648
2649
2650
2651
2652
2653
2654
2655
2656
2657
2658
2659
2660
2661
2662
2663
2664
2665
2666
2667
2668
2669
2670
2671
2672
2673
2674
2675
2676
2677
2678
2679
2680
2681
2682
2683
2684
2685
2686
2687
2688
2689
2690
2691
2692
2693
2694
2695
2696
2697
2698
2699
2700
2701
2702
2703
2704
2705
2706
2707
2708
2709
2710
2711
2712
2713
2714
2715
2716
2717
2718
2719
2720
2721
2722
2723
2724
2725
2726
2727
2728
2729
2730
2731
2732
2733
2734
2735
2736
2737
2738
2739
2740
2741
2742
2743
2744
2745
2746
2747
2748
2749
2750
2751
2752
2753
2754
2755
2756
2757
2758
2759
2760
2761
2762
2763
2764
2765
2766
2767
2768
2769
2770
2771
2772
2773
2774
2775
2776
2777
2778
2779
2780
2781
2782
2783
2784
2785
2786
2787
2788
2789
2790
2791
2792
2793
2794
2795
2796
2797
2798
2799
2800
2801
2802
2803
2804
2805
2806
2807
2808
2809
2810
2811
2812
2813
2814
2815
2816
2817
2818
2819
2820
2821
2822
2823
2824
2825
2826
2827
2828
2829
2830
2831
2832
2833
2834
2835
2836
2837
2838
2839
2840
2841
2842
2843
2844
2845
2846
2847
2848
2849
2850
2851
2852
2853
2854
2855
2856
2857
2858
2859
2860
2861
2862
2863
2864
2865
2866
2867
2868
2869
2870
2871
2872
2873
2874
2875
2876
2877
2878
2879
2880
2881
2882
2883
2884
2885
2886
2887
2888
2889
2890
2891
2892
2893
2894
2895
2896
2897
2898
2899
2900
2901
2902
2903
2904
2905
2906
2907
2908
2909
2910
2911
2912
2913
2914
2915
2916
2917
2918
2919
2920
2921
2922
2923
2924
2925
2926
2927
2928
2929
2930
2931
2932
2933
2934
2935
2936
2937
2938
2939
2940
2941
2942
2943
2944
2945
2946
2947
2948
2949
2950
2951
2952
2953
2954
2955
2956
2957
2958
2959
2960
2961
2962
2963
2964
2965
2966
2967
2968
2969
2970
2971
2972
2973
2974
2975
2976
2977
2978
2979
2980
2981
2982
2983
2984
2985
2986
2987
2988
2989
2990
2991
2992
2993
2994
2995
2996
2997
2998
2999
3000
3001
3002
3003
3004
3005
3006
3007
3008
3009
3010
3011
3012
3013
3014
3015
3016
3017
3018
3019
3020
3021
3022
3023
3024
3025
3026
3027
3028
3029
3030
3031
3032
3033
3034
3035
3036
3037
3038
3039
3040
3041
3042
3043
3044
3045
3046
3047
3048
3049
3050
3051
3052
3053
3054
3055
3056
3057
3058
3059
3060
3061
3062
3063
3064
3065
3066
3067
3068
3069
3070
3071
3072
3073
3074
3075
3076
3077
3078
3079
3080
3081
3082
3083
3084
3085
# File 'lib/tbd/psi.rb', line 1495

def process(model = nil, argh = {})
  mth = "TBD::#{__callee__}"
  cl  = OpenStudio::Model::Model
  tbd = { io: nil, surfaces: {} }
  return mismatch("model", model, cl, mth, DBG, tbd) unless model.is_a?(cl)
  return mismatch("argh", argh, Hash, mth, DBG, tbd) unless argh.is_a?(Hash)

  argh                 = {}           if argh.empty?
  argh[:option       ] = ""       unless argh.key?(:option)
  argh[:io_path      ] = nil      unless argh.key?(:io_path)
  argh[:schema_path  ] = nil      unless argh.key?(:schema_path)
  argh[:parapet      ] = true     unless argh.key?(:parapet)
  argh[:uprate_walls ] = false    unless argh.key?(:uprate_walls)
  argh[:uprate_roofs ] = false    unless argh.key?(:uprate_roofs)
  argh[:uprate_floors] = false    unless argh.key?(:uprate_floors)
  argh[:wall_ut      ] = 0        unless argh.key?(:wall_ut)
  argh[:roof_ut      ] = 0        unless argh.key?(:roof_ut)
  argh[:floor_ut     ] = 0        unless argh.key?(:floor_ut)
  argh[:wall_option  ] = ""       unless argh.key?(:wall_option)
  argh[:roof_option  ] = ""       unless argh.key?(:roof_option)
  argh[:floor_option ] = ""       unless argh.key?(:floor_option)
  argh[:gen_ua       ] = false    unless argh.key?(:gen_ua)
  argh[:ua_ref       ] = ""       unless argh.key?(:ua_ref)
  argh[:gen_kiva     ] = false    unless argh.key?(:gen_kiva)
  argh[:reset_kiva   ] = false    unless argh.key?(:reset_kiva)
  argh[:sub_tol      ] = TBD::TOL unless argh.key?(:sub_tol)

  # Ensure true or false: whether to generate KIVA inputs.
  unless [true, false].include?(argh[:gen_kiva])
    return invalid("generate KIVA option", mth, 0, DBG, tbd)
  end

  # Ensure true or false: whether to first purge (existing) KIVA inputs.
  unless [true, false].include?(argh[:reset_kiva])
    return invalid("reset KIVA option", mth, 0, DBG, tbd)
  end

  # Create the Topolys Model.
  t_model = Topolys::Model.new

  # "true" if any space/zone holds valid setpoint temperatures. With invalid
  # inputs, these 2x methods return "false", ignoring any
  # setpoint-based logic, e.g. semi-heated spaces (DEBUG errors are logged).
  heated = heatingTemperatureSetpoints?(model)
  cooled = coolingTemperatureSetpoints?(model)
  argh[:setpoints] = heated || cooled

  model.getSurfaces.sort_by { |s| s.nameString }.each do |s|
    # Fetch key attributes of opaque surfaces (and any linked sub surfaces).
    # Method returns nil with invalid input (see logs); TBD ignores them.
    surface = properties(s, argh)
    tbd[:surfaces][s.nameString] = surface unless surface.nil?
  end

  return empty("TBD surfaces", mth, ERR, tbd) if tbd[:surfaces].empty?

  # TBD only derates constructions of opaque surfaces in CONDITIONED spaces,
  # ... if facing outdoors or facing UNENCLOSED/UNCONDITIONED spaces.
  tbd[:surfaces].each do |id, surface|
    surface[:deratable] = false
    next unless surface[:conditioned]
    next     if surface[:ground     ]

    unless surface[:boundary].downcase == "outdoors"
      next unless tbd[:surfaces].key?(surface[:boundary])
      next     if tbd[:surfaces][surface[:boundary]][:conditioned]
    end

    if surface.key?(:index)
      surface[:deratable] = true
    else
      log(ERR, "Skipping '#{id}': insulating layer? (#{mth})")
    end
  end

  # Sort subsurfaces before processing.
  [:windows, :doors, :skylights].each do |holes|
    tbd[:surfaces].values.each do |surface|
      next unless surface.key?(holes)

      surface[holes] = surface[holes].sort_by { |_, s| s[:minz] }.to_h
    end
  end

  # Split "surfaces" hash into "floors", "ceilings" and "walls" hashes.
  floors   = tbd[:surfaces].select { |_, s| s[:type] == :floor   }
  ceilings = tbd[:surfaces].select { |_, s| s[:type] == :ceiling }
  walls    = tbd[:surfaces].select { |_, s| s[:type] == :wall    }

  floors   = floors.sort_by   { |_, s| [s[:minz], s[:space]] }.to_h
  ceilings = ceilings.sort_by { |_, s| [s[:minz], s[:space]] }.to_h
  walls    = walls.sort_by    { |_, s| [s[:minz], s[:space]] }.to_h

  # Fetch OpenStudio shading surfaces & key attributes.
  shades = {}

  model.getShadingSurfaces.each do |s|
    id    = s.nameString
    group = s.shadingSurfaceGroup
    log(ERR, "Can't process '#{id}' transformation (#{mth})") if group.empty?
    next                                                      if group.empty?

    group   = group.get
    tr      = transforms(group)
    t       = tr[:t] if tr[:t] && tr[:r]

    log(ERR, "Can't process '#{id}' transformation (#{mth})") unless t
    next                                                      unless t

    space   = group.space
    tr[:r] += space.get.directionofRelativeNorth unless space.empty?
    n       = truNormal(s, tr[:r])
    log(ERR, "Can't process '#{id}' true normal (#{mth})") unless n
    next                                                   unless n

    points = (t * s.vertices).map { |v| Topolys::Point3D.new(v.x, v.y, v.z) }

    minz = ( points.map { |p| p.z } ).min

    shades[id] = { group: group, points: points, minz: minz, n: n }
  end

  # Mutually populate TBD & Topolys surfaces. Keep track of created "holes".
  holes         = {}
  floor_holes   = dads(t_model, floors)
  ceiling_holes = dads(t_model, ceilings)
  wall_holes    = dads(t_model, walls)

  holes.merge!(floor_holes)
  holes.merge!(ceiling_holes)
  holes.merge!(wall_holes)
  dads(t_model, shades)

  # Loop through Topolys edges and populate TBD edge hash. Initially, there
  # should be a one-to-one correspondence between Topolys and TBD edge
  # objects. Use Topolys-generated identifiers as unique edge hash keys.
  edges = {}

  # Start with hole edges.
  holes.each do |id, wire|
    wire.edges.each do |e|
      i  = e.id
      l  = e.length
      ex = edges.key?(i)

      edges[i] = { length: l, v0: e.v0, v1: e.v1, surfaces: {} } unless ex

      next if edges[i][:surfaces].key?(wire.attributes[:id])

      edges[i][:surfaces][wire.attributes[:id]] = { wire: wire.id }
    end
  end

  # Next, floors, ceilings & walls; then shades.
  faces(floors  , edges)
  faces(ceilings, edges)
  faces(walls   , edges)
  faces(shades  , edges)

  # Purge existing KIVA objects from model.
  if argh[:reset_kiva]
    kva = false
    kva = true unless model.getSurfacePropertyExposedFoundationPerimeters.empty?
    kva = true unless model.getFoundationKivas.empty?

    if kva
      if argh[:gen_kiva]
        resetKIVA(model, "Foundation")
      else
        resetKIVA(model, "Ground")
      end
    end
  end

  # Generate OSM Kiva settings and objects if foundation-facing floors.
  # Returns false if partial failure (log failure eventually).
  kiva(model, walls, floors, edges) if argh[:gen_kiva]

  # Thermal bridging characteristics of edges are determined - in part - by
  # relative polar position of linked surfaces (or wires) around each edge.
  # This attribute is key in distinguishing concave from convex edges.
  #
  # For each linked surface (or rather surface wires), set polar position
  # around edge with respect to a reference vector (perpendicular to the
  # edge), +clockwise as one is looking in the opposite position of the edge
  # vector. For instance, a vertical edge has a reference vector pointing
  # North - surfaces eastward of the edge are (0°,180°], while surfaces
  # westward of the edge are (180°,360°].
  #
  # Much of the following code is of a topological nature, and should ideally
  # (or eventually) become available functionality offered by Topolys. Topolys
  # "wrappers" like TBD are good, short-term test beds to identify desired
  # features for future Topolys enhancements.
  zenith = Topolys::Vector3D.new(0, 0, 1).freeze
  north  = Topolys::Vector3D.new(0, 1, 0).freeze
  east   = Topolys::Vector3D.new(1, 0, 0).freeze

  edges.values.each do |edge|
    origin     = edge[:v0].point
    terminal   = edge[:v1].point
    dx         = (origin.x - terminal.x).abs
    dy         = (origin.y - terminal.y).abs
    dz         = (origin.z - terminal.z).abs
    horizontal = dz < TOL
    vertical   = dx < TOL && dy < TOL
    edge_V     = terminal - origin
    next if edge_V.magnitude < TOL

    edge_plane = Topolys::Plane3D.new(origin, edge_V)

    if vertical
      reference_V = north.dup
    elsif horizontal
      reference_V = zenith.dup
    else # project zenith vector unto edge plane
      reference = edge_plane.project(origin + zenith)
      reference_V = reference - origin
    end

    edge[:surfaces].each do |id, surface|
      # Loop through each linked wire and determine farthest point from
      # edge while ensuring candidate point is not aligned with edge.
      t_model.wires.each do |wire|
        next unless surface[:wire] == wire.id # should be a unique match

        normal       = tbd[:surfaces][id][:n]   if tbd[:surfaces].key?(id)
        normal       = holes[id].attributes[:n] if holes.key?(id)
        normal       = shades[id][:n]           if shades.key?(id)
        farthest     = Topolys::Point3D.new(origin.x, origin.y, origin.z)
        farthest_V   = farthest - origin # zero magnitude, initially
        farthest_mag = 0

        wire.points.each do |point|
          next if point == origin
          next if point == terminal

          point_on_plane = edge_plane.project(point)
          origin_point_V = point_on_plane - origin
          point_V_mag    = origin_point_V.magnitude
          next unless point_V_mag > TOL
          next unless point_V_mag > farthest_mag

          farthest    = point
          farthest_V  = origin_point_V
          fathest_mag = point_V_mag
        end

        angle  = reference_V.angle(farthest_V)
        angle  = 0 if angle.nil?
        adjust = false # adjust angle [180°, 360°] if necessary

        if vertical
          adjust = true if east.dot(farthest_V) < -TOL
        else
          dN  = north.dot(farthest_V)
          dN1 = north.dot(farthest_V).abs - 1

          if dN.abs < TOL || dN1.abs < TOL
            adjust = true if east.dot(farthest_V) < -TOL
          else
            adjust = true if dN < -TOL
          end
        end

        angle  = 2 * Math::PI - angle if adjust
        angle -= 2 * Math::PI         if (angle - 2 * Math::PI).abs < TOL
        surface[:angle ] = angle
        farthest_V.normalize!
        surface[:polar ] = farthest_V
        surface[:normal] = normal
      end # end of edge-linked, surface-to-wire loop
    end # end of edge-linked surface loop

    edge[:horizontal] = horizontal
    edge[:vertical  ] = vertical
    edge[:surfaces  ] = edge[:surfaces].sort_by{ |_, p| p[:angle] }.to_h
  end # end of edge loop

  # Topolys edges may constitute thermal bridges (and therefore thermally
  # derate linked OpenStudio opaque surfaces), depending on a number of
  # factors such as surface type, space conditioning and boundary conditions.
  # Thermal bridging attributes (type & PSI-value pairs) are grouped into PSI
  # sets, normally accessed through the :option user argument (in the
  # OpenStudio Measure interface).
  #
  # Process user-defined TBD JSON file inputs if file exists & valid:
  #   :io holds valid TBD JSON file entries
  #   :psi holds TBD PSI sets (built-in defaults + those on file)
  #   :khi holds TBD KHI points (built-in defaults + those on file)
  #
  # Without an input JSON file, a valid 'json' Hash simply holds:
  #   :io[:building][:psi] ... a single valid, default PSI set for all edges
  #   :psi                 ... built-in TBD PSI sets
  #   :khi                 ... built-in TBD KHI points
  json = inputs(tbd[:surfaces], edges, argh)

  # A user-defined TBD JSON input file can hold a number of anomalies that
  # won't affect results, such as custom PSI sets that aren't referenced
  # elsewhere (similar to OpenStudio materials on file that aren't referenced
  # by any OpenStudio construction). This may trigger 'warnings' in the log
  # file, but they're in principle benign.
  #
  # A user-defined JSON input file can instead hold a number of more serious
  # anomalies that risk generating erroneous or unintended results. They're
  # logged as well, yet it remains up to the user to decide how serious a risk
  # this may be. If a custom edge is defined on file (e.g., "expansion joint"
  # thermal bridge instead of a "transition") yet TBD is unable to match
  # it against OpenStudio and/or Topolys edges (or surfaces), then TBD
  # will log this as an error while simply 'skipping' the anomaly (TBD will
  # otherwise ignore the requested change and pursue its processes).
  #
  # There are 2 types of errors that are considered FATAL when processing
  # user-defined TBD JSON input files:
  #   - incorrect JSON formatting of the input file (can't parse)
  #   - TBD is unable to identify a 'complete' building-level PSI set
  #     (either a bad argument from the Measure, or bad input on file).
  #
  # ... in such circumstances, TBD will halt all processes and exit while
  # signaling to OpenStudio to halt its own processes (e.g., not launch an
  # EnergyPlus simulation). This is similar to accessing an invalid .osm file.
  return tbd if fatal?

  psi    = json[:io][:building][:psi] # default building PSI on file
  shorts = json[:psi].shorthands(psi)

  if shorts[:has].empty? || shorts[:val].empty?
    log(FTL, "Invalid or incomplete building PSI set (#{mth})")
    return tbd
  end

  edges.values.each do |edge|
    next unless edge.key?(:surfaces)

    deratables = []
    set        = {}

    edge[:surfaces].keys.each do |id|
      next unless tbd[:surfaces].key?(id)

      deratables << id if tbd[:surfaces][id][:deratable]
    end

    next if deratables.empty?

    if edge.key?(:io_type)
      bdg = json[:psi].safe(psi, edge[:io_type]) # building safe type fallback
      edge[:sets] = {} unless edge.key?(:sets)
      edge[:sets][edge[:io_type]] = shorts[:val][bdg] # building safe fallback
      set[edge[:io_type]] = shorts[:val][bdg]
      edge[:psi] = set

      if edge.key?(:io_set) && json[:psi].set.key?(edge[:io_set])
        type = json[:psi].safe(edge[:io_set], edge[:io_type])
        edge[:set] = edge[:io_set] if type
      end

      match = true
    end

    edge[:surfaces].keys.each do |id|
      break    if match
      next unless tbd[:surfaces].key?(id)
      next unless deratables.include?(id)

      # Evaluate current set content before processing a new linked surface.
      is                    = {}
      is[:doorhead        ] = set.keys.to_s.include?("doorhead")
      is[:doorsill        ] = set.keys.to_s.include?("doorsill")
      is[:doorjamb        ] = set.keys.to_s.include?("doorjamb")
      is[:skylighthead    ] = set.keys.to_s.include?("skylighthead")
      is[:skylightsill    ] = set.keys.to_s.include?("skylightsill")
      is[:skylightjamb    ] = set.keys.to_s.include?("skylightjamb")
      is[:spandrel        ] = set.keys.to_s.include?("spandrel")
      is[:corner          ] = set.keys.to_s.include?("corner")
      is[:parapet         ] = set.keys.to_s.include?("parapet")
      is[:roof            ] = set.keys.to_s.include?("roof")
      is[:ceiling         ] = set.keys.to_s.include?("ceiling")
      is[:party           ] = set.keys.to_s.include?("party")
      is[:grade           ] = set.keys.to_s.include?("grade")
      is[:balcony         ] = set.keys.to_s.include?("balcony")
      is[:balconysill     ] = set.keys.to_s.include?("balconysill")
      is[:balconydoorsill ] = set.keys.to_s.include?("balconydoorsill")
      is[:rimjoist        ] = set.keys.to_s.include?("rimjoist")

      if is.empty?
        is[:head] = set.keys.to_s.include?("head")
        is[:sill] = set.keys.to_s.include?("sill")
        is[:jamb] = set.keys.to_s.include?("jamb")
      end

      # Label edge as ...
      #         :head,         :sill,         :jamb (vertical fenestration)
      #     :doorhead,     :doorsill,     :doorjamb (opaque door)
      # :skylighthead, :skylightsill, :skylightjamb (all other cases)
      #
      # ... if linked to:
      #   1x subsurface (vertical or non-vertical)
      edge[:surfaces].keys.each do |i|
        break    if is[:head        ]
        break    if is[:sill        ]
        break    if is[:jamb        ]
        break    if is[:doorhead    ]
        break    if is[:doorsill    ]
        break    if is[:doorjamb    ]
        break    if is[:skylighthead]
        break    if is[:skylightsill]
        break    if is[:skylightjamb]
        next     if deratables.include?(i)
        next unless holes.key?(i)

        # In most cases, subsurface edges simply delineate the rough opening
        # of its base surface (here, a "gardian"). Door sills, corner windows,
        # as well as a subsurface header aligned with a plenum "floor"
        # (ceiling tiles), are common instances where a subsurface edge links
        # 2x (opaque) surfaces. Deratable surface "id" may not be the gardian
        # of subsurface "i" - the latter may be a neighbour. The single
        # surface to derate is not the gardian in such cases.
        gardian = deratables.size == 1 ? id : ""
        target  = gardian

        # Retrieve base surface's subsurfaces.
        windows   = tbd[:surfaces][id].key?(:windows)
        doors     = tbd[:surfaces][id].key?(:doors)
        skylights = tbd[:surfaces][id].key?(:skylights)

        windows   =   windows ? tbd[:surfaces][id][:windows  ] : {}
        doors     =     doors ? tbd[:surfaces][id][:doors    ] : {}
        skylights = skylights ? tbd[:surfaces][id][:skylights] : {}

        # The gardian is "id" if subsurface "ids" holds "i".
        ids = windows.keys + doors.keys + skylights.keys

        if gardian.empty?
          other = deratables.first == id ? deratables.last : deratables.first

          gardian = ids.include?(i) ? id : other
          target  = ids.include?(i) ? other : id

          windows   = tbd[:surfaces][gardian].key?(:windows)
          doors     = tbd[:surfaces][gardian].key?(:doors)
          skylights = tbd[:surfaces][gardian].key?(:skylights)

          windows   =   windows ? tbd[:surfaces][gardian][:windows  ] : {}
          doors     =     doors ? tbd[:surfaces][gardian][:doors    ] : {}
          skylights = skylights ? tbd[:surfaces][gardian][:skylights] : {}

          ids = windows.keys + doors.keys + skylights.keys
        end

        unless ids.include?(i)
          log(ERR, "Orphaned subsurface #{i} (mth)")
          next
        end

        window   =   windows.key?(i) ?   windows[i] : {}
        door     =     doors.key?(i) ?     doors[i] : {}
        skylight = skylights.key?(i) ? skylights[i] : {}

        sub = window   unless window.empty?
        sub = door     unless door.empty?
        sub = skylight unless skylight.empty?

        window = sub[:type] == :window
        door   = sub[:type] == :door
        glazed = door && sub.key?(:glazed) && sub[:glazed]

        s1      = edge[:surfaces][target]
        s2      = edge[:surfaces][i      ]
        concave = concave?(s1, s2)
        convex  = convex?(s1, s2)
        flat    = !concave && !convex

        # Subsurface edges are tagged as head, sill or jamb, regardless of
        # building PSI set subsurface-related tags. If the latter is simply
        # :fenestration, then its single PSI factor is systematically
        # assigned to e.g. a window's :head, :sill & :jamb edges.
        #
        # Additionally, concave or convex variants also inherit from the base
        # type if undefined in the PSI set.
        #
        # If a subsurface is not horizontal, TBD tags any horizontal edge as
        # either :head or :sill based on the polar angle of the subsurface
        # around the edge vs sky zenith. Otherwise, all other subsurface edges
        # are tagged as :jamb.
        if ((s2[:normal].dot(zenith)).abs - 1).abs < TOL # horizontal surface
          if glazed || window
            set[:jamb       ] = shorts[:val][:jamb       ] if flat
            set[:jambconcave] = shorts[:val][:jambconcave] if concave
            set[:jambconvex ] = shorts[:val][:jambconvex ] if convex
             is[:jamb       ] = true
          elsif door
            set[:doorjamb       ] = shorts[:val][:doorjamb       ] if flat
            set[:doorjambconcave] = shorts[:val][:doorjambconcave] if concave
            set[:doorjambconvex ] = shorts[:val][:doorjambconvex ] if convex
             is[:doorjamb       ] = true
          else
            set[:skylightjamb       ] = shorts[:val][:skylightjamb       ] if flat
            set[:skylightjambconcave] = shorts[:val][:skylightjambconcave] if concave
            set[:skylightjambconvex ] = shorts[:val][:skylightjambconvex ] if convex
             is[:skylightjamb       ] = true
          end
        else
          if glazed || window
            if edge[:horizontal]
              if s2[:polar].dot(zenith) < 0
                set[:head       ] = shorts[:val][:head       ] if flat
                set[:headconcave] = shorts[:val][:headconcave] if concave
                set[:headconvex ] = shorts[:val][:headconvex ] if convex
                 is[:head       ] = true
              else
                set[:sill       ] = shorts[:val][:sill       ] if flat
                set[:sillconcave] = shorts[:val][:sillconcave] if concave
                set[:sillconvex ] = shorts[:val][:sillconvex ] if convex
                 is[:sill       ] = true
              end
            else
              set[:jamb       ] = shorts[:val][:jamb       ] if flat
              set[:jambconcave] = shorts[:val][:jambconcave] if concave
              set[:jambconvex ] = shorts[:val][:jambconvex ] if convex
               is[:jamb       ] = true
            end
          elsif door
            if edge[:horizontal]
              if s2[:polar].dot(zenith) < 0

                set[:doorhead       ] = shorts[:val][:doorhead       ] if flat
                set[:doorheadconcave] = shorts[:val][:doorheadconcave] if concave
                set[:doorheadconvex ] = shorts[:val][:doorheadconvex ] if convex
                 is[:doorhead       ] = true
              else
                set[:doorsill       ] = shorts[:val][:doorsill       ] if flat
                set[:doorsillconcave] = shorts[:val][:doorsillconcave] if concave
                set[:doorsillconvex ] = shorts[:val][:doorsillconvex ] if convex
                 is[:doorsill       ] = true
              end
            else
              set[:doorjamb       ] = shorts[:val][:doorjamb       ] if flat
              set[:doorjambconcave] = shorts[:val][:doorjambconcave] if concave
              set[:doorjambconvex ] = shorts[:val][:doorjambconvex ] if convex
               is[:doorjamb       ] = true
            end
          else
            if edge[:horizontal]
              if s2[:polar].dot(zenith) < 0
                set[:skylighthead       ] = shorts[:val][:skylighthead       ] if flat
                set[:skylightheadconcave] = shorts[:val][:skylightheadconcave] if concave
                set[:skylightheadconvex ] = shorts[:val][:skylightheadconvex ] if convex
                 is[:skylighthead       ] = true
              else
                set[:skylightsill       ] = shorts[:val][:skylightsill       ] if flat
                set[:skylightsillconcave] = shorts[:val][:skylightsillconcave] if concave
                set[:skylightsillconvex ] = shorts[:val][:skylightsillconvex ] if convex
                 is[:skylightsill       ] = true
              end
            else
              set[:skylightjamb       ] = shorts[:val][:skylightjamb       ] if flat
              set[:skylightjambconcave] = shorts[:val][:skylightjambconcave] if concave
              set[:skylightjambconvex ] = shorts[:val][:skylightjambconvex ] if convex
               is[:skylightjamb       ] = true
            end
          end
        end
      end

      # Label edge as :spandrel if linked to:
      #   1x deratable, non-spandrel wall
      #   1x deratable, spandrel wall
      edge[:surfaces].keys.each do |i|
        break     if is[:spandrel]
        break unless deratables.size == 2
        break unless walls.key?(id)
        break unless walls[id][:spandrel]
        next      if i == id
        next  unless deratables.include?(i)
        next  unless walls.key?(i)
        next      if walls[i][:spandrel]

        s1      = edge[:surfaces][id]
        s2      = edge[:surfaces][i ]
        concave = concave?(s1, s2)
        convex  = convex?(s1, s2)
        flat    = !concave && !convex

        set[:spandrel       ] = shorts[:val][:spandrel       ] if flat
        set[:spandrelconcave] = shorts[:val][:spandrelconcave] if concave
        set[:spandrelconvex ] = shorts[:val][:spandrelconvex ] if convex
         is[:spandrel       ] = true
      end

      # Label edge as :cornerconcave or :cornerconvex if linked to:
      #   2x deratable walls & f(relative polar wall vectors around edge)
      edge[:surfaces].keys.each do |i|
        break     if is[:corner]
        break unless deratables.size == 2
        break unless walls.key?(id)
        next      if i == id
        next  unless deratables.include?(i)
        next  unless walls.key?(i)

        s1      = edge[:surfaces][id]
        s2      = edge[:surfaces][i]
        concave = concave?(s1, s2)
        convex  = convex?(s1, s2)

        set[:cornerconcave] = shorts[:val][:cornerconcave] if concave
        set[:cornerconvex ] = shorts[:val][:cornerconvex ] if convex
         is[:corner       ] = true
      end

      # Label edge as :ceiling if linked to:
      #   +1 deratable surface(s)
      #   1x underatable CONDITIONED floor linked to an unoccupied space
      #   1x adjacent CONDITIONED ceiling linked to an occupied space
      edge[:surfaces].keys.each do |i|
        break     if is[:ceiling]
        break unless deratables.size > 0
        break     if floors.key?(id)
        next      if i == id
        next  unless floors.key?(i)
        next      if floors[i][:ground     ]
        next  unless floors[i][:conditioned]
        next      if floors[i][:occupied   ]

        ceiling = floors[i][:boundary]
        next unless ceilings.key?(ceiling)
        next unless ceilings[ceiling][:conditioned]
        next unless ceilings[ceiling][:occupied   ]

        other = deratables.first unless deratables.first == id
        other = deratables.last  unless deratables.last  == id
        other = id                   if deratables.size  == 1

        s1      = edge[:surfaces][id]
        s2      = edge[:surfaces][other]
        concave = concave?(s1, s2)
        convex  = convex?(s1, s2)
        flat    = !concave && !convex

        set[:ceiling       ] = shorts[:val][:ceiling       ] if flat
        set[:ceilingconcave] = shorts[:val][:ceilingconcave] if concave
        set[:ceilingconvex ] = shorts[:val][:ceilingconvex ] if convex
         is[:ceiling       ] = true
      end

      # Label edge as :parapet/:roof if linked to:
      #   1x deratable wall
      #   1x deratable ceiling
      edge[:surfaces].keys.each do |i|
        break     if is[:parapet]
        break     if is[:roof   ]
        break unless deratables.size == 2
        break unless ceilings.key?(id)
        next      if i == id
        next  unless deratables.include?(i)
        next  unless walls.key?(i)

        s1      = edge[:surfaces][id]
        s2      = edge[:surfaces][i ]
        concave = concave?(s1, s2)
        convex  = convex?(s1, s2)
        flat    = !concave && !convex

        if argh[:parapet]
          set[:parapet       ] = shorts[:val][:parapet       ] if flat
          set[:parapetconcave] = shorts[:val][:parapetconcave] if concave
          set[:parapetconvex ] = shorts[:val][:parapetconvex ] if convex
           is[:parapet       ] = true
        else
          set[:roof       ] = shorts[:val][:roof       ] if flat
          set[:roofconcave] = shorts[:val][:roofconcave] if concave
          set[:roofconvex ] = shorts[:val][:roofconvex ] if convex
           is[:roof       ] = true
        end
      end

      # Label edge as :party if linked to:
      #   1x OtherSideCoefficients surface
      #   1x (only) deratable surface
      edge[:surfaces].keys.each do |i|
        break     if is[:party]
        break unless deratables.size == 1
        next      if i == id
        next  unless tbd[:surfaces].key?(i)
        next      if holes.key?(i)
        next      if shades.key?(i)

        facing = tbd[:surfaces][i][:boundary].downcase
        next unless facing == "othersidecoefficients"

        s1      = edge[:surfaces][id]
        s2      = edge[:surfaces][i ]
        concave = concave?(s1, s2)
        convex  = convex?(s1, s2)
        flat    = !concave && !convex

        set[:party       ] = shorts[:val][:party       ] if flat
        set[:partyconcave] = shorts[:val][:partyconcave] if concave
        set[:partyconvex ] = shorts[:val][:partyconvex ] if convex
         is[:party       ] = true
      end

      # Label edge as :grade if linked to:
      #   1x surface (e.g. slab or wall) facing ground
      #   1x surface (i.e. wall) facing outdoors
      edge[:surfaces].keys.each do |i|
        break     if is[:grade]
        break unless deratables.size == 1
        next      if i == id
        next  unless tbd[:surfaces].key?(i)
        next  unless tbd[:surfaces][i].key?(:ground)
        next  unless tbd[:surfaces][i][:ground]

        s1      = edge[:surfaces][id]
        s2      = edge[:surfaces][i]
        concave = concave?(s1, s2)
        convex  = convex?(s1, s2)
        flat    = !concave && !convex

        set[:grade       ] = shorts[:val][:grade       ] if flat
        set[:gradeconcave] = shorts[:val][:gradeconcave] if concave
        set[:gradeconvex ] = shorts[:val][:gradeconvex ] if convex
         is[:grade       ] = true
      end

      # Label edge as :rimjoist, :balcony, :balconysill or :balconydoorsill,
      # if linked to:
      #   1x deratable surface
      #   1x CONDITIONED floor
      #   1x shade (optional)
      #   1x subsurface (optional)
      balcony         = false
      balconysill     = false # vertical fenestration
      balconydoorsill = false # opaque door

      # Despite referring to 'sill' or 'doorsill', a 'balconysill' or
      # 'balconydoorsill' edge may instead link (rarer) cases of balcony and a
      # fenestration/door head. ASHRAE 90.1 2022 does not make the distinction
      # between sill vs head when intermediate floor, balcony and vertical
      # fenestration meet. 'Sills' are simply the most common occurrence.
      edge[:surfaces].keys.each do |i|
        break if is[:ceiling]
        break if balcony
        next  if i == id

        balcony = shades.key?(i)
      end

      edge[:surfaces].keys.each do |i|
        break unless balcony
        break     if balconysill
        break     if balconydoorsill
        next      if i == id
        next  unless holes.key?(i)

        # Deratable surface "id" may not be the gardian of "i" (see sills).
        gardian = deratables.size == 1 ? id : ""
        target  = gardian

        # Retrieve base surface's subsurfaces.
        windows   = tbd[:surfaces][id].key?(:windows)
        doors     = tbd[:surfaces][id].key?(:doors)
        skylights = tbd[:surfaces][id].key?(:skylights)

        windows   =   windows ? tbd[:surfaces][id][:windows  ] : {}
        doors     =     doors ? tbd[:surfaces][id][:doors    ] : {}
        skylights = skylights ? tbd[:surfaces][id][:skylights] : {}

        # The gardian is "id" if subsurface "ids" holds "i".
        ids = windows.keys + doors.keys + skylights.keys

        if gardian.empty?
          other = deratables.first == id ? deratables.last : deratables.first

          gardian = ids.include?(i) ? id : other
          target  = ids.include?(i) ? other : id

          windows   = tbd[:surfaces][gardian].key?(:windows)
          doors     = tbd[:surfaces][gardian].key?(:doors)
          skylights = tbd[:surfaces][gardian].key?(:skylights)

          windows   =   windows ? tbd[:surfaces][gardian][:windows  ] : {}
          doors     =     doors ? tbd[:surfaces][gardian][:doors    ] : {}
          skylights = skylights ? tbd[:surfaces][gardian][:skylights] : {}

          ids = windows.keys + doors.keys + skylights.keys
        end

        unless ids.include?(i)
          log(ERR, "Balcony sill: orphaned subsurface #{i} (mth)")
          next
        end

        window   =   windows.key?(i) ?   windows[i] : {}
        door     =     doors.key?(i) ?     doors[i] : {}
        skylight = skylights.key?(i) ? skylights[i] : {}

        sub = window   unless window.empty?
        sub = door     unless door.empty?
        sub = skylight unless skylight.empty?

        window = sub[:type] == :window
        door   = sub[:type] == :door
        glazed = door && sub.key?(:glazed) && sub[:glazed]

        if window || glazed
          balconysill = true
        elsif door
          balconydoorsill = true
        end
      end

      edge[:surfaces].keys.each do |i|
        break     if is[:ceiling        ]
        break     if is[:rimjoist       ]
        break     if is[:balcony        ]
        break     if is[:balconysill    ]
        break     if is[:balconydoorsill]
        break unless deratables.size > 0
        break     if floors.key?(id)
        next      if i == id
        next  unless floors.key?(i)
        next      if floors[i][:ground     ]
        next  unless floors[i][:conditioned]

        other = deratables.first unless deratables.first == id
        other = deratables.last  unless deratables.last  == id
        other = id                   if deratables.size  == 1

        s1      = edge[:surfaces][id]
        s2      = edge[:surfaces][other]
        concave = concave?(s1, s2)
        convex  = convex?(s1, s2)
        flat    = !concave && !convex

        if balconydoorsill
          set[:balconydoorsill       ] = shorts[:val][:balconydoorsill       ] if flat
          set[:balconydoorsillconcave] = shorts[:val][:balconydoorsillconcave] if concave
          set[:balconydoorsillconvex ] = shorts[:val][:balconydoorsillconvex ] if convex
           is[:balconydoorsill       ] = true
        elsif balconysill
          set[:balconysill           ] = shorts[:val][:balconysill           ] if flat
          set[:balconysillconcave    ] = shorts[:val][:balconysillconcave    ] if concave
          set[:balconysillconvex     ] = shorts[:val][:balconysillconvex     ] if convex
           is[:balconysill           ] = true
        elsif balcony
          set[:balcony               ] = shorts[:val][:balcony               ] if flat
          set[:balconyconcave        ] = shorts[:val][:balconyconcave        ] if concave
          set[:balconyconvex         ] = shorts[:val][:balconyconvex         ] if convex
           is[:balcony               ] = true
        else
          set[:rimjoist              ] = shorts[:val][:rimjoist              ] if flat
          set[:rimjoistconcave       ] = shorts[:val][:rimjoistconcave       ] if concave
          set[:rimjoistconvex        ] = shorts[:val][:rimjoistconvex        ] if convex
           is[:rimjoist              ] = true
        end
      end
    end # edge's surfaces loop

    edge[:psi] = set unless set.empty?
    edge[:set] = psi unless set.empty?
  end # edge loop

  # Tracking (mild) transitions between deratable surfaces around edges that
  # have not been previously tagged.
  edges.values.each do |edge|
    deratable = false
    next     if edge.key?(:psi)
    next unless edge.key?(:surfaces)

    edge[:surfaces].keys.each do |id|
      next unless tbd[:surfaces].key?(id)
      next unless tbd[:surfaces][id][:deratable]

      deratable = tbd[:surfaces][id][:deratable]
    end

    next unless deratable

    edge[:psi] = { transition: 0.000 }
    edge[:set] = json[:io][:building][:psi]
  end

  # 'Unhinged' subsurfaces, like Tubular Daylight Device (TDD) domes,
  # usually don't share edges with parent surfaces, e.g. floating 300mm above
  # parent roof surface. Add parent surface ID to unhinged edges.
  edges.values.each do |edge|
    next     if edge.key?(:psi)
    next unless edge.key?(:surfaces)
    next unless edge[:surfaces].size == 1

    id        = edge[:surfaces].first.first
    next unless holes.key?(id)
    next unless holes[id].attributes.key?(:unhinged)
    next unless holes[id].attributes[:unhinged]

    subsurface = model.getSubSurfaceByName(id)
    next      if subsurface.empty?

    subsurface = subsurface.get
    surface    = subsurface.surface
    next      if surface.empty?

    nom        = surface.get.nameString
    next  unless tbd[:surfaces].key?(nom)
    next  unless tbd[:surfaces][nom].key?(:conditioned)
    next  unless tbd[:surfaces][nom][:conditioned]

    edge[:surfaces][nom] = {}

    set        = {}
    set[:jamb] = shorts[:val][:jamb]
    edge[:psi] = set
    edge[:set] = json[:io][:building][:psi]
  end

  if json[:io]
    # Reset subsurface U-factors (if on file).
    if json[:io].key?(:subsurfaces)
      json[:io][:subsurfaces].each do |sub|
        match = false
        next unless sub.key?(:id)
        next unless sub.key?(:usi)

        tbd[:surfaces].values.each do |surface|
          break if match

          [:windows, :doors, :skylights].each do |types|
            break    if match
            next unless surface.key?(types)

            surface[types].each do |id, opening|
              break    if match
              next unless opening.key?(:u)
              next unless sub[:id] == id

              opening[:u] = sub[:usi]
              match       = true
            end
          end
        end
      end
    end

    # Reset wall-to-roof intersection type (if on file) ... per group.
    [:stories, :spacetypes, :spaces].each do |groups|
      key = :story
      key = :stype if groups == :spacetypes
      key = :space if groups == :spaces
      next unless json[:io].key?(groups)

      json[:io][groups].each do |group|
        next unless group.key?(:id)
        next unless group.key?(:parapet)

        edges.values.each do |edge|
          match = false
          next unless edge.key?(:psi)
          next unless edge.key?(:surfaces)
          next     if edge.key?(:io_type)

          edge[:surfaces].keys.each do |id|
            break    if match
            next unless tbd[:surfaces].key?(id)
            next unless tbd[:surfaces][id].key?(key)

            match = group[:id] == tbd[:surfaces][id][key].nameString
          end

          next unless match

          parapets = edge[:psi].keys.select {|ty| ty.to_s.include?("parapet")}
          roofs    = edge[:psi].keys.select {|ty| ty.to_s.include?("roof")}

          if group[:parapet]
            next unless parapets.empty?
            next     if roofs.empty?

            type = :parapet
            type = :parapetconcave if roofs.first.to_s.include?("concave")
            type = :parapetconvex  if roofs.first.to_s.include?("convex")

            edge[:psi][type] = shorts[:val][type]
            roofs.each {|ty| edge[:psi].delete(ty)}
          else
            next unless roofs.empty?
            next     if parapets.empty?

            type = :roof
            type = :roofconcave if parapets.first.to_s.include?("concave")
            type = :roofconvex  if parapets.first.to_s.include?("convex")

            edge[:psi][type] = shorts[:val][type]

            parapets.each { |ty| edge[:psi].delete(ty) }
          end
        end
      end
    end

    # Reset wall-to-roof intersection type (if on file) - individual surfaces.
    if json[:io].key?(:surfaces)
      json[:io][:surfaces].each do |surface|
        next unless surface.key?(:parapet)
        next unless surface.key?(:id)

        edges.values.each do |edge|
          next     if edge.key?(:io_type)
          next unless edge.key?(:psi)
          next unless edge.key?(:surfaces)
          next unless edge[:surfaces].keys.include?(surface[:id])

          parapets = edge[:psi].keys.select {|ty| ty.to_s.include?("parapet")}
          roofs    = edge[:psi].keys.select {|ty| ty.to_s.include?("roof")}


          if surface[:parapet]
            next unless parapets.empty?
            next     if roofs.empty?

            type = :parapet
            type = :parapetconcave if roofs.first.to_s.include?("concave")
            type = :parapetconvex  if roofs.first.to_s.include?("convex")

            edge[:psi][type] = shorts[:val][type]
            roofs.each {|ty| edge[:psi].delete(ty)}
          else
            next unless roofs.empty?
            next     if parapets.empty?

            type = :roof
            type = :roofconcave if parapets.first.to_s.include?("concave")
            type = :roofconvex  if parapets.first.to_s.include?("convex")

            edge[:psi][type] = shorts[:val][type]
            parapets.each {|ty| edge[:psi].delete(ty)}
          end
        end
      end
    end

    # A priori, TBD applies (default) :building PSI types and values to
    # individual edges. If a TBD JSON input file holds custom PSI sets for:
    #   :stories
    #   :spacetypes
    #   :surfaces
    #   :edges
    # ... that may apply to individual edges, then the default :building PSI
    # types and/or values are overridden, as follows:
    #   custom :stories    PSI sets trump :building PSI sets
    #   custom :spacetypes PSI sets trump aforementioned PSI sets
    #   custom :spaces     PSI sets trump aforementioned PSI sets
    #   custom :surfaces   PSI sets trump aforementioned PSI sets
    #   custom :edges      PSI sets trump aforementioned PSI sets
    [:stories, :spacetypes, :spaces].each do |groups|
      key = :story
      key = :stype if groups == :spacetypes
      key = :space if groups == :spaces
      next unless json[:io].key?(groups)

      json[:io][groups].each do |group|
        next unless group.key?(:id)
        next unless group.key?(:psi)
        next unless json[:psi].set.key?(group[:psi])

        sh = json[:psi].shorthands(group[:psi])
        next if sh[:val].empty?

        edges.values.each do |edge|
          match = false
          next unless edge.key?(:psi)
          next unless edge.key?(:surfaces)
          next     if edge.key?(:io_set)

          edge[:surfaces].keys.each do |id|
            break    if match
            next unless tbd[:surfaces].key?(id)
            next unless tbd[:surfaces][id].key?(key)

            match = group[:id] == tbd[:surfaces][id][key].nameString
          end

          next unless match

          set                       = {}
          edge[groups]              = {} unless edge.key?(groups)
          edge[groups][group[:psi]] = {}

          if edge.key?(:io_type)
            safer = json[:psi].safe(group[:psi], edge[:io_type])
            set[edge[:io_type]] = sh[:val][safer] if safer
          else
            edge[:psi].keys.each do |type|
              safer = json[:psi].safe(group[:psi], type)
              set[type] = sh[:val][safer] if safer
            end
          end

          edge[groups][group[:psi]] = set unless set.empty?
        end
      end

      # TBD/Topolys edges will generally be linked to more than one surface
      # and hence to more than one group. It is possible for a TBD JSON file
      # to hold 2x group PSI sets that end up targetting one or more edges
      # common to both groups. In such cases, TBD retains the most conductive
      # PSI type/value from either group PSI set.
      edges.values.each do |edge|
        next unless edge.key?(:psi)
        next unless edge.key?(groups)

        edge[:psi].keys.each do |type|
          vals = {}

          edge[groups].keys.each do |set|
            sh = json[:psi].shorthands(set)
            next if sh[:val].empty?

            safer     = json[:psi].safe(set, type)
            vals[set] = sh[:val][safer] if safer
          end

          next if vals.empty?

          edge[:psi ][type] = vals.values.max
          edge[:sets]       = {} unless edge.key?(:sets)
          edge[:sets][type] = vals.key(vals.values.max)
        end
      end
    end

    if json[:io].key?(:surfaces)
      json[:io][:surfaces].each do |surface|
        next unless surface.key?(:psi)
        next unless surface.key?(:id)
        next unless tbd[:surfaces].key?(surface[:id ])
        next unless json[:psi].set.key?(surface[:psi])

        sh = json[:psi].shorthands(surface[:psi])
        next if sh[:val].empty?

        edges.values.each do |edge|
          next     if edge.key?(:io_set)
          next unless edge.key?(:psi)
          next unless edge.key?(:surfaces)
          next unless edge[:surfaces].keys.include?(surface[:id])

          s   = edge[:surfaces][surface[:id]]
          set = {}

          if edge.key?(:io_type)
            safer = json[:psi].safe(surface[:psi], edge[:io_type])
            set[:io_type] = sh[:val][safer] if safer
          else
            edge[:psi].keys.each do |type|
              safer = json[:psi].safe(surface[:psi], type)
              set[type] = sh[:val][safer] if safer
            end
          end

          next if set.empty?

          s[:psi] = set
          s[:set] = surface[:psi]
        end
      end

      # TBD/Topolys edges will generally be linked to more than one surface. A
      # TBD JSON file may hold 2x surface PSI sets that target a shared edge.
      # TBD retains the most conductive PSI type/value from either set.
      edges.values.each do |edge|
        next unless edge.key?(:psi)
        next unless edge.key?(:surfaces)

        edge[:psi].keys.each do |type|
          vals = {}

          edge[:surfaces].each do |id, s|
            next unless s.key?(:psi)
            next unless s.key?(:set)
            next     if s[:set].empty?

            sh = json[:psi].shorthands(s[:set])
            next if sh[:val].empty?

            safer         = json[:psi].safe(s[:set], type)
            vals[s[:set]] = sh[:val][safer] if safer
          end

          next if vals.empty?

          edge[:psi ][type] = vals.values.max
          edge[:sets]       = {} unless edge.key?(:sets)
          edge[:sets][type] = vals.key(vals.values.max)
        end
      end
    end

    # Loop through all customized edges on file w/w/o a custom PSI set.
    edges.values.each do |edge|
      next unless edge.key?(:psi)
      next unless edge.key?(:io_type)
      next unless edge.key?(:surfaces)

      if edge.key?(:io_set)
        next unless json[:psi].set.key?(edge[:io_set])

        set = edge[:io_set]
      else
        next unless edge[:sets].key?(edge[:io_type])
        next unless json[:psi].set.key?(edge[:sets][edge[:io_type]])

        set = edge[:sets][edge[:io_type]]
      end

      sh = json[:psi].shorthands(set)
      next if sh[:val].empty?

      safer = json[:psi].safe(set, edge[:io_type])
      next unless safer

      if edge.key?(:io_set)
        edge[:psi] = {}
        edge[:set] = edge[:io_set]
      else
        edge[:sets] = {} unless edge.key?(:sets)
        edge[:sets][edge[:io_type]] = sh[:val][safer]
      end

      edge[:psi][edge[:io_type]] = sh[:val][safer]
    end
  end

  # Fetch edge multipliers for subsurfaces, if applicable.
  edges.values.each do |edge|
    next     if edge.key?(:mult) # skip if already assigned
    next unless edge.key?(:surfaces)
    next unless edge.key?(:psi)

    ok = false

    edge[:psi].keys.each do |k|
      break if ok

      jamb = k.to_s.include?("jamb")
      sill = k.to_s.include?("sill")
      head = k.to_s.include?("head")
      ok   = jamb || sill || head
    end

    next unless ok  # if OK, edge links subsurface(s) ... yet which one(s)?

    edge[:surfaces].each do |id, surface|
      next unless tbd[:surfaces].key?(id) # look up parent (opaque) surface

      [:windows, :doors, :skylights].each do |subtypes|
        next unless tbd[:surfaces][id].key?(subtypes)

        tbd[:surfaces][id][subtypes].each do |nom, sub|
          next unless edge[:surfaces].key?(nom)
          next unless sub[:mult] > 1

          # An edge may be tagged with (potentially conflicting) multipliers.
          # This is only possible if the edge links 2 subsurfaces, e.g. a
          # shared jamb between window & door. By default, TBD tags common
          # subsurface edges as (mild) "transitions" (i.e. PSI 0 W/K•m), so
          # there would be no point in assigning an edge multiplier. Users
          # can however reset an edge type via a TBD JSON input file (e.g.
          # "joint" instead of "transition"). It would be a very odd choice,
          # but TBD doesn't prohibit it. If linked subsurfaces have different
          # multipliers (e.g. 2 vs 3), TBD tracks the highest value.
          edge[:mult] = sub[:mult] unless edge.key?(:mult)
          edge[:mult] = sub[:mult]     if sub[:mult] > edge[:mult]
        end
      end
    end
  end

  # Unless a user has set the thermal bridge type of an individual edge via
  # JSON input, reset any subsurface's head, sill or jamb edges as (mild)
  # transitions when in close proximity to another subsurface edge. Both
  # edges' origin and terminal vertices must be in close proximity. Edges
  # of unhinged subsurfaces are ignored.
  edges.each do |id, edge|
    nb    = 0 # linked subsurfaces (i.e. "holes")
    match = false
    next if edge.key?(:io_type) # skip if set in JSON
    next unless edge.key?(:v0)
    next unless edge.key?(:v1)
    next unless edge.key?(:psi)
    next unless edge.key?(:surfaces)

    edge[:surfaces].keys.each do |identifier|
      break    if match
      next unless holes.key?(identifier)

      if holes[identifier].attributes.key?(:unhinged)
        nb = 0 if holes[identifier].attributes[:unhinged]
        break  if holes[identifier].attributes[:unhinged]
      end

      nb += 1
      match = true if nb > 1
    end

    if nb == 1 # linking 1x subsurface, search for 1x other.
      e1 = { v0: edge[:v0].point, v1: edge[:v1].point }

      edges.each do |nom, e|
        nb = 0
        break    if match
        next     if nom == id
        next     if e.key?(:io_type)
        next unless e.key?(:psi)
        next unless e.key?(:surfaces)

        e[:surfaces].keys.each do |identifier|
          next unless holes.key?(identifier)

          if holes[identifier].attributes.key?(:unhinged)
            nb = 0 if holes[identifier].attributes[:unhinged]
            break  if holes[identifier].attributes[:unhinged]
          end

          nb += 1
        end

        next unless nb == 1 # only process edge if linking 1x subsurface

        e2 = { v0: e[:v0].point, v1: e[:v1].point }
        match = matches?(e1, e2, argh[:sub_tol])
      end
    end

    next unless match

    edge[:psi] = { transition: 0.000 }
    edge[:set] = json[:io][:building][:psi]
  end

  # Loop through each edge and assign heat loss to linked surfaces.
  edges.each do |identifier, edge|
    next unless  edge.key?(:psi)

    rsi        = 0
    max        = edge[:psi   ].values.max
    type       = edge[:psi   ].key(max)
    length     = edge[:length]
    length    *= edge[:mult  ] if edge.key?(:mult)
    bridge     = { psi: max, type: type, length: length }
    deratables = {}
    apertures  = {}

    if edge.key?(:sets) && edge[:sets].key?(type)
      edge[:set] = edge[:sets][type] unless edge.key?(:io_set)
    end

    # Retrieve valid linked surfaces as deratables.
    edge[:surfaces].each do |id, s|
      next unless tbd[:surfaces].key?(id)
      next unless tbd[:surfaces][id][:deratable]

      deratables[id] = s
    end

    edge[:surfaces].each { |id, s| apertures[id] = s if holes.key?(id) }
    next if apertures.size > 1 # edge links 2x openings

    # Prune dad if edge links an opening, its dad and an uncle.
    if deratables.size > 1 && apertures.size > 0
      deratables.each do |id, deratable|
        [:windows, :doors, :skylights].each do |types|
          next unless tbd[:surfaces][id].key?(types)

          tbd[:surfaces][id][types].keys.each do |sub|
            deratables.delete(id) if apertures.key?(sub)
          end
        end
      end
    end

    next if deratables.empty?

    # Sum RSI of targeted insulating layer from each deratable surface.
    deratables.each do |id, deratable|
      next unless tbd[:surfaces][id].key?(:r)

      rsi += tbd[:surfaces][id][:r]
    end

    # Assign heat loss from thermal bridges to surfaces, in proportion to
    # insulating layer thermal resistance.
    deratables.each do |id, deratable|
      ratio = 0
      ratio = tbd[:surfaces][id][:r] / rsi if rsi > 0.001
      loss  = bridge[:psi] * ratio
      b     = { psi: loss, type: bridge[:type], length: length, ratio: ratio }
      tbd[:surfaces][id][:edges] = {} unless tbd[:surfaces][id].key?(:edges)
      tbd[:surfaces][id][:edges][identifier] = b
    end
  end

  # Assign thermal bridging heat loss [in W/K] to each deratable surface.
  tbd[:surfaces].each do |id, surface|
    next unless surface.key?(:edges)

    surface[:heatloss] = 0
    e = surface[:edges].values

    e.each { |edge| surface[:heatloss] += edge[:psi] * edge[:length] }
  end

  # Add point conductances (W/K x count), in TBD JSON file (under surfaces).
  tbd[:surfaces].each do |id, s|
    next unless s[:deratable]
    next unless json[:io]
    next unless json[:io].key?(:surfaces)

    json[:io][:surfaces].each do |surface|
      next unless surface.key?(:khis)
      next unless surface.key?(:id)
      next unless surface[:id] == id

      surface[:khis].each do |k|
        next unless k.key?(:id)
        next unless k.key?(:count)
        next unless json[:khi].point.key?(k[:id])
        next unless json[:khi].point[k[:id]] > 0.001

        s[:heatloss]  = 0 unless s.key?(:heatloss)
        s[:heatloss] += json[:khi].point[k[:id]] * k[:count]
        s[:pts     ]  = {} unless s.key?(:pts)

        s[:pts][k[:id]] = { val: json[:khi].point[k[:id]], n: k[:count] }
      end
    end
  end

  # If user has selected a Ut to meet, e.g. argh'ments:
  #   :uprate_walls
  #   :wall_ut
  #   :wall_option ... (same triple arguments for roofs and exposed floors)
  #
  # ... first 'uprate' targeted insulation layers (see ua.rb) before derating.
  # Check for new argh keys [:wall_uo], [:roof_uo] and/or [:floor_uo].
  up = argh[:uprate_walls] || argh[:uprate_roofs] || argh[:uprate_floors]
  uprate(model, tbd[:surfaces], argh) if up

  # Derated (cloned) constructions are unique to each deratable surface.
  # Unique construction names are prefixed with the surface name,
  # and suffixed with " tbd", indicating that the construction is
  # henceforth thermally derated. The " tbd" expression is also key in
  # avoiding inadvertent derating - TBD will not derate constructions
  # (or rather layered materials) having " tbd" in their OpenStudio name.
  tbd[:surfaces].each do |id, surface|
    next unless surface.key?(:construction)
    next unless surface.key?(:index)
    next unless surface.key?(:ltype)
    next unless surface.key?(:r)
    next unless surface.key?(:edges)
    next unless surface.key?(:heatloss)
    next unless surface[:heatloss].abs > TOL

    s = model.getSurfaceByName(id)
    next if s.empty?

    s = s.get

    index     = surface[:index       ]
    current_c = surface[:construction]
    c         = current_c.clone(model).to_LayeredConstruction.get
    m         = nil
    m         = derate(id, surface, c) if index
    # m may be nilled simply because the targeted construction has already
    # been derated, i.e. holds " tbd" in its name. Names of cloned/derated
    # constructions (due to TBD) include the surface name (since derated
    # constructions are now unique to each surface) and the suffix " c tbd".
    if m
      c.setLayer(index, m)
      c.setName("#{id} c tbd")
      current_R = rsi(current_c, s.filmResistance)

      # In principle, the derated "ratio" could be calculated simply by
      # accessing a surface's uFactor. Yet air layers within constructions
      # (not air films) are ignored in OpenStudio's uFactor calculation.
      # An example would be 25mm-50mm pressure-equalized air gaps behind
      # brick veneer. This is not always compliant to some energy codes.
      # TBD currently factors-in air gap (and exterior cladding) R-values.
      #
      # If one comments out the following loop (3 lines), tested surfaces
      # with air layers will generate discrepencies between the calculed RSi
      # value above and the inverse of the uFactor. All other surface
      # constructions pass the test.
      #
      # if ((1/current_R) - s.uFactor.to_f).abs > 0.005
      #   puts "#{s.nameString} - Usi:#{1/current_R} UFactor: #{s.uFactor}"
      # end
      s.setConstruction(c)

      # If the derated surface construction separates CONDITIONED space from
      # UNCONDITIONED or UNENCLOSED space, then derate the adjacent surface
      # construction as well (unless defaulted).
      if s.outsideBoundaryCondition.downcase == "surface"
        unless s.adjacentSurface.empty?
          adjacent = s.adjacentSurface.get
          nom      = adjacent.nameString
          default  = adjacent.isConstructionDefaulted == false

          if default  && tbd[:surfaces].key?(nom)
            current_cc = tbd[:surfaces][nom][:construction]
            cc         = current_cc.clone(model).to_LayeredConstruction.get
            cc.setLayer(tbd[:surfaces][nom][:index], m)
            cc.setName("#{nom} c tbd")
            adjacent.setConstruction(cc)
          end
        end
      end

      # Compute updated RSi value from layers.
      updated_c = s.construction.get.to_LayeredConstruction.get
      updated_R = rsi(updated_c, s.filmResistance)
      ratio     = -(current_R - updated_R) * 100 / current_R

      surface[:ratio] = ratio if ratio.abs > TOL
      surface[:u    ] = 1 / current_R # un-derated U-factors (for UA')
    end
  end

  # Ensure deratable surfaces have U-factors (even if NOT derated).
  tbd[:surfaces].each do |id, surface|
    next unless surface[:deratable]
    next unless surface.key?(:construction)
    next     if surface.key?(:u)

    s   = model.getSurfaceByName(id)
    msg = "Skipping missing surface '#{id}' (#{mth})"
    log(ERR, msg) if s.empty?
    next          if s.empty?

    surface[:u] = 1.0 / rsi(surface[:construction], s.get.filmResistance)
  end

  json[:io][:edges] = []
  # Enrich io with TBD/Topolys edge info before returning:
  #   1. edge custom PSI set, if on file
  #   2. edge PSI type
  #   3. edge length (m)
  #   4. edge origin & end vertices
  #   5. array of linked outside- or ground-facing surfaces
  edges.values.each do |e|
    next unless e.key?(:psi)
    next unless e.key?(:set)

    v    = e[:psi].values.max
    set  = e[:set]
    t    = e[:psi].key(v)
    l    = e[:length]
    l   *= e[:mult] if e.key?(:mult)
    edge = { psi: set, type: t, length: l, surfaces: e[:surfaces].keys }

    edge[:v0x] = e[:v0].point.x
    edge[:v0y] = e[:v0].point.y
    edge[:v0z] = e[:v0].point.z
    edge[:v1x] = e[:v1].point.x
    edge[:v1y] = e[:v1].point.y
    edge[:v1z] = e[:v1].point.z

    json[:io][:edges] << edge
  end

  if json[:io][:edges].empty?
    json[:io].delete(:edges)
  else
    json[:io][:edges].sort_by { |e| [ e[:v0x], e[:v0y], e[:v0z],
                                      e[:v1x], e[:v1y], e[:v1z] ] }
  end

  # Populate UA' trade-off reference values (optional).
  if argh[:gen_ua] && argh[:ua_ref]
    case argh[:ua_ref]
    when "code (Quebec)"
      qc33(tbd[:surfaces], json[:psi], argh[:setpoints])
    end
  end

  tbd[:io       ] = json[:io     ]
  argh[:io      ] = tbd[:io      ]
  argh[:surfaces] = tbd[:surfaces]
  argh[:version ] = model.getVersion.versionIdentifier

  tbd
end

#properties(surface = nil, argh = {}) ⇒ Hash?

Fetches OpenStudio surface properties, including opening areas & vertices.

Parameters:

  • surface (OpenStudio::Model::Surface) (defaults to: nil)

    a surface

  • argh (Hash) (defaults to: {})

    TBD arguments

Options Hash (argh):

  • :setpoints (Bool)

    whether model holds thermal zone setpoints

Returns:

  • (Hash)

    TBD surface with key attributes (see )

  • (nil)

    if invalid input (see logs)



276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
# File 'lib/tbd/geo.rb', line 276

def properties(surface = nil, argh = {})
  mth = "TBD::#{__callee__}"
  cl1 = OpenStudio::Model::Surface
  cl2 = OpenStudio::Model::LayeredConstruction
  cl3 = Hash
  return mismatch("surface", surface, cl1, mth) unless surface.is_a?(cl1)
  return mismatch("argh"   , argh   , cl3, mth) unless argh.is_a?(cl3)

  nom    = surface.nameString
  surf   = {}
  subs   = {}
  fd     = false
  return invalid("#{nom}",     mth, 1, ERR) if poly(surface).empty?
  return empty("#{nom} space", mth,    ERR) if surface.space.empty?

  space  = surface.space.get
  stype  = space.spaceType
  story  = space.buildingStory
  tr     = transforms(space)
  return   invalid("#{nom} transform", mth, 0, ERR) unless tr[:t] && tr[:r]

  t      = tr[:t]
  n      = truNormal(surface, tr[:r])
  return   invalid("#{nom} normal", mth, 0, ERR) unless n

  type   = surface.surfaceType.downcase
  facing = surface.outsideBoundaryCondition
  setpts = setpoints(space)

  if facing.downcase == "surface"
    empty = surface.adjacentSurface.empty?
    return invalid("#{nom}: adjacent surface", mth, 0, ERR) if empty

    facing = surface.adjacentSurface.get.nameString
  end

  unless surface.construction.empty?
    construction = surface.construction.get.to_LayeredConstruction

    unless construction.empty?
      construction = construction.get
      lyr          = insulatingLayer(construction)
      lyr[:index]  = nil unless lyr[:index].is_a?(Numeric)
      lyr[:index]  = nil unless lyr[:index] >= 0
      lyr[:index]  = nil unless lyr[:index] < construction.layers.size

      if lyr[:index]
        surf[:construction] = construction
        # index: ... of layer/material (to derate) within construction
        # ltype: either :massless (RSi) or :standard (k + d)
        # r    : initial RSi value of the indexed layer to derate
        surf[:index] = lyr[:index]
        surf[:ltype] = lyr[:type ]
        surf[:r    ] = lyr[:r    ]
      end
    end
  end

  unless argh.key?(:setpoints)
    heat = heatingTemperatureSetpoints?(model)
    cool = coolingTemperatureSetpoints?(model)
    argh[:setpoints] = heat || cool
  end

  if argh[:setpoints]
    surf[:heating] = setpts[:heating] unless setpts[:heating].nil?
    surf[:cooling] = setpts[:cooling] unless setpts[:cooling].nil?
  else
    surf[:heating] = 21.0
    surf[:cooling] = 24.0
  end

  surf[:conditioned] = surf.key?(:heating) || surf.key?(:cooling)
  surf[:space      ] = space
  surf[:occupied   ] = space.partofTotalFloorArea
  surf[:boundary   ] = facing
  surf[:ground     ] = surface.isGroundSurface
  surf[:type       ] = :floor
  surf[:type       ] = :ceiling      if type.include?("ceiling")
  surf[:type       ] = :wall         if type.include?("wall"   )
  surf[:stype      ] = stype.get unless stype.empty?
  surf[:story      ] = story.get unless story.empty?
  surf[:n          ] = n
  surf[:gross      ] = surface.grossArea
  surf[:filmRSI    ] = surface.filmResistance
  surf[:spandrel   ] = spandrel?(surface)

  surface.subSurfaces.sort_by { |s| s.nameString }.each do |s|
    next if poly(s).empty?

    id  = s.nameString
    typ = surface.surfaceType.downcase

    unless (3..4).cover?(s.vertices.size)
      log(ERR, "Skipping '#{id}': vertex # 3 or 4 (#{mth})")
      next
    end

    vec  = s.vertices
    area = s.grossArea
    mult = s.multiplier

    # An OpenStudio subsurface has a "type" (string), either defaulted during
    # initialization or explicitely set by the user (from a built-in list):
    #
    #   OpenStudio::Model::SubSurface.validSubSurfaceTypeValues
    #   - "FixedWindow"
    #   - "OperableWindow"
    #   - "Door"
    #   - "GlassDoor"
    #   - "OverheadDoor"
    #   - "Skylight"
    #   - "TubularDaylightDome"
    #   - "TubularDaylightDiffuser"
    typ = s.subSurfaceType.downcase

    # An OpenStudio default subsurface construction set can hold unique
    # constructions assigned for each of these admissible types. In addition,
    # type assignment determines whether frame/divider attributes can be
    # linked to a subsurface (this shortlist has evolved between OpenStudio
    # releases). Type assignment is relied upon when calculating (admissible)
    # fenestration areas. TBD also relies on OpenStudio subsurface type
    # assignment, with resulting TBD tags being a bit more concise, e.g.:
    #
    #   - :window includes "FixedWindow" and "OperableWindow"
    #   - :door includes "Door", "OverheadWindow" and "GlassDoor"
    #     ... a (roof) access roof hatch should be assigned as a "Door"
    #   - :skylight includes "Skylight", "TubularDaylightDome", etc.
    #
    type = :skylight
    type = :window if typ.include?("window") # operable or not
    type = :door   if typ.include?("door")   # fenestrated or not

    # In fact, ANY subsurface other than :window or :door is tagged as
    # :skylight, e.g. a glazed floor opening (CN, Calgary, Tokyo towers). This
    # happens to reflect OpenStudio default initialization behaviour. For
    # instance, a subsurface added to an exposed (horizontal) floor in
    # OpenStudio is automatically assigned a "Skylight" type. This is similar
    # to the auto-assignment of (opaque) walls, roof/ceilings and floors
    # (based on surface tilt) in OpenStudio.
    #
    # When it comes to major thermal bridging, ASHRAE 90.1 (2022) makes a
    # clear distinction between "vertical fenestration" (a defined term) and
    # all other subsurfaces. "Vertical fenestration" would include both
    # instances of "Window", as well as "GlassDoor". It would exclude however
    # a non-fenestrated "door" (another defined term), like "Door" &
    # "OverheadDoor", as well as skylights. TBD tracks relevant subsurface
    # attributes via a handful of boolean variables:
    glazed   = type == :door && typ.include?("glass")   # fenestrated door
    tubular  =                  typ.include?("tubular") # dome or diffuser
    domed    =                  typ.include?("dome")    # (tubular) dome
    unhinged = false                                    # (tubular) dome

    # It would be tempting (and simple) to have TBD further validate whether a
    # "GlassDoor" is actually integrated within a (vertical) wall. The
    # automated type assignment in OpenStudio is very simple and reliable (as
    # discussed in the preceding paragraphs), yet users can nonetheless reset
    # this explicitly. For instance, while a vertical surface may indeed be
    # auto-assigned "Wall", a modeller can just as easily reset its type as
    # "Floor". Although OpenStudio supports 90.1 rules by default, it's not
    # enforced. TBD retains the same approach: for whatever osbcur reason a
    # modeller may decide (and hopefully the "authority having jurisdiction"
    # may authorize) to reset a wall as a "Floor" or a roof skylight as a
    # "GlassDoor", TBD maintains the same OpenStudio policy. Either OpenStudio
    # (and consequently EnergyPlus) sub/surface type assignment is reliable,
    # or it is not.

    # Determine if TDD dome subsurface is 'unhinged', i.e. unconnected to its
    # base surface (not same 3D plane).
    if domed
      unhinged = true unless s.plane.equal(surface.plane)
      n = s.outwardNormal if unhinged
    end

    if area < TOL
      log(ERR, "Skipping '#{id}': gross area ~zero (#{mth})")
      next
    end

    c = s.construction

    if c.empty?
      log(ERR, "Skipping '#{id}': missing construction (#{mth})")
      next
    end

    c = c.get.to_LayeredConstruction

    if c.empty?
      log(WRN, "Skipping '#{id}': subs limited to #{cl2} (#{mth})")
      next
    end

    c = c.get

    # A subsurface may have an overall U-factor set by the user - a less
    # accurate option, yet easier to process (and often the only option
    # available). With EnergyPlus' "simple window" model, a subsurface's
    # construction has a single SimpleGlazing material/layer holding the
    # whole product U-factor.
    #
    #   https://bigladdersoftware.com/epx/docs/9-6/engineering-reference/
    #   window-calculation-module.html#simple-window-model
    #
    # TBD will instead rely on Tubular Daylighting Device (TDD) effective
    # dome-to-diffuser RSi-factors (if valid).
    #
    #   https://bigladdersoftware.com/epx/docs/9-6/engineering-reference/
    #   daylighting-devices.html#tubular-daylighting-devices
    #
    # In other cases, TBD will recover an 'additional property' tagged
    # "uFactor", assigned either to the individual subsurface itself, or else
    # assigned to its referenced construction (a more generic fallback).
    #
    # If all else fails, TBD will calculate an approximate whole product
    # U-factor by adding up the subsurface's layered construction material
    # thermal resistances (as well as the subsurface's parent surface film
    # resistances). This is the least reliable option, especially if
    # subsurfaces have Frame & Divider objects, or irregular geometry.
    u = s.uFactor
    u = u.get unless u.empty?

    if tubular & s.respond_to?(:daylightingDeviceTubular) # OSM > v3.3.0
      unless s.daylightingDeviceTubular.empty?
        r = s.daylightingDeviceTubular.get.effectiveThermalResistance
        u = 1 / r if r > TOL
      end
    end

    unless u.is_a?(Numeric)
      u = s.additionalProperties.getFeatureAsDouble("uFactor")
    end

    unless u.is_a?(Numeric)
      r = rsi(c, surface.filmResistance)

      if r < TOL
        log(ERR, "Skipping '#{id}': U-factor unavailable (#{mth})")
        next
      end

      u = 1 / r
    end

    frame = s.allowWindowPropertyFrameAndDivider
    frame = false if s.windowPropertyFrameAndDivider.empty?

    if frame
      fd    = true
      width = s.windowPropertyFrameAndDivider.get.frameWidth
      vec   = offset(vec, width, 300)
      area  = OpenStudio.getArea(vec)

      if area.empty?
        log(ERR, "Skipping '#{id}': invalid offset (#{mth})")
        next
      end

      area = area.get
    end

    sub = { v:        s.vertices,
            points:   vec,
            n:        n,
            gross:    s.grossArea,
            area:     area,
            mult:     mult,
            type:     type,
            u:        u,
            unhinged: unhinged }

    sub[:glazed] = true if glazed
    subs[id    ] = sub
  end

  valid = true
  # Test for conflicts (with fits?, overlaps?) between sub/surfaces to
  # determine whether to keep original points or switch to std::vector of
  # revised coordinates, offset by Frame & Divider frame width. This will
  # also inadvertently catch pre-existing (yet nonetheless invalid)
  # OpenStudio inputs (without Frame & Dividers).
  subs.each do |id, sub|
    break unless fd
    break unless valid

    valid = fits?(sub[:points], surface.vertices)
    log(ERR, "Skipping '#{id}': can't fit in '#{nom}' (#{mth})") unless valid

    subs.each do |i, sb|
      break unless valid
      next      if i == id

      if overlaps?(sb[:points], sub[:points])
        log(ERR, "Skipping '#{id}': overlaps sibling '#{i}' (#{mth})")
        valid = false
      end
    end
  end

  if fd
    subs.values.each { |sub| sub[:gross ] = sub[:area ] }     if valid
    subs.values.each { |sub| sub[:points] = sub[:v    ] } unless valid
    subs.values.each { |sub| sub[:area  ] = sub[:gross] } unless valid
  end

  subarea = 0

  subs.values.each { |sub| subarea += sub[:area] * sub[:mult] }

  surf[:net] = surf[:gross] - subarea

  # Tranform final Point 3D sets, and store.
  pts = (t * surface.vertices).map { |v| Topolys::Point3D.new(v.x, v.y, v.z) }

  surf[:points] = pts
  surf[:minz  ] = ( pts.map { |pt| pt.z } ).min

  subs.each do |id, sub|
    pts = (t * sub[:points]).map { |v| Topolys::Point3D.new(v.x, v.y, v.z) }

    sub[:points] = pts
    sub[:minz  ] = ( pts.map { |p| p.z } ).min

    [:windows, :doors, :skylights].each do |types|
      type = types.slice(0..-2).to_sym
      next unless sub[:type] == type

      surf[types]     = {} unless surf.key?(types)
      surf[types][id] = sub
    end
  end

  surf
end

#qc33(s = {}, sets = nil, spts = true) ⇒ Bool, false

Sets reference values for points, edges & surfaces (& subsurfaces) to compute Quebec energy code (Section 3.3) UA’ comparison (2021).

Parameters:

  • s (Hash) (defaults to: {})

    TBD surfaces (keys: Openstudio surface names)

  • sets (TBD::PSI) (defaults to: nil)

    a TBD model’s PSI sets

  • spts (Bool) (defaults to: true)

    whether OpenStudio model holds heating/cooling setpoints

Options Hash (s):

  • :deratable (Bool)

    whether surface is deratable, s[]

  • :type (:wall, :ceiling, :floor)

    TBD surface type

  • :heating (#to_f)

    applicable heating setpoint temperature in °C

  • :cooling (#to_f)

    applicable cooling setpoint temperature in °C

  • :windows (Hash)

    TBD surface-specific windows e.g. s[]

  • :doors (Hash)

    TBD surface-specific doors

  • :skylights (Hash)

    TBD surface-specific skylights

  • :pts (Hash)

    point thermal bridges, e.g. s[] see KHI class

  • :edges (Hash)

    TBD edges (keys: Topolys edge identifiers)

Returns:

  • (Bool)

    whether successful in generating UA’ reference values

  • (false)

    if invalid inputs (see logs)



437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
# File 'lib/tbd/ua.rb', line 437

def qc33(s = {}, sets = nil, spts = true)
  mth = "TBD::#{__callee__}"
  cl1 = Hash
  cl2 = TBD::PSI
  return mismatch("surfaces", s, cl1, mth, DBG, false) unless s.is_a?(cl1)
  return mismatch("sets",  sets, cl1, mth, DBG, false) unless sets.is_a?(cl2)

  shorts = sets.shorthands("code (Quebec)")
  empty  = shorts[:has].empty? || shorts[:val].empty?
  log(DBG, "Missing QC PSI set for 3.3 UA' tradeoff (#{mth})") if empty
  return false                                                 if empty

  ok = [true, false].include?(spts)
  log(DBG, "setpoints must be true or false for 3.3 UA' tradeoff") unless ok
  return false                                                     unless ok

  s.each do |id, surface|
    next unless surface.key?(:deratable)
    next unless surface[:deratable]
    next unless surface.key?(:type)

    heating = -50     if spts
    cooling =  50     if spts
    heating =  21 unless spts
    cooling =  24 unless spts
    heating = surface[:heating] if surface.key?(:heating)
    cooling = surface[:cooling] if surface.key?(:cooling)

    # Start with surface U-factors.
    ref = 1 / 5.46
    ref = 1 / 3.60 if surface[:type] == :wall

    # Adjust for lower heating setpoint (assumes -25°C design conditions).
    ref *= 43 / (heating + 25) if heating < 18 && cooling > 40

    surface[:ref] = ref

    if surface.key?(:skylights) # loop through subsurfaces
      ref = 2.85
      ref *= 43 / (heating + 25) if heating < 18 && cooling > 40

      surface[:skylights].values.map { |skylight| skylight[:ref] = ref }
    end

    if surface.key?(:windows)
      ref = 2.0
      ref *= 43 / (heating + 25) if heating < 18 && cooling > 40

      surface[:windows].values.map { |window| window[:ref] = ref }
    end

    if surface.key?(:doors)
      surface[:doors].each do |i, door|
        ref = 0.9
        ref = 2.0 if door.key?(:glazed) && door[:glazed]
        ref *= 43 / (heating + 25) if heating < 18 && cooling > 40
        door[:ref] = ref
      end
    end

    # Loop through point thermal bridges.
    surface[:pts].map { |i, pt| pt[:ref] = 0.5 } if surface.key?(:pts)

    # Loop through linear thermal bridges.
    if surface.key?(:edges)
      surface[:edges].values.each do |edge|
        next unless edge.key?(:type)
        next unless edge.key?(:ratio)

        safe = sets.safe("code (Quebec)", edge[:type])
        edge[:ref] = shorts[:val][safe] * edge[:ratio] if safe
      end
    end
  end

  true
end

#resetKIVA(model = nil, boundary = "Foundation") ⇒ Bool, false

Purge existing KIVA-related objects in an OpenStudio model. Resets ground- facing surface outside boundary condition to “Ground” or “Foundation”.

Parameters:

  • model (OpenStudio::Model::Model) (defaults to: nil)

    a model

  • boundary ("Ground", "Foundation") (defaults to: "Foundation")

    new outside boundary condition

Returns:

  • (Bool)

    true if model is free of KIVA-related objects

  • (false)

    if invalid input (see logs)



706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
# File 'lib/tbd/geo.rb', line 706

def resetKIVA(model = nil, boundary = "Foundation")
  mth = "TBD::#{__callee__}"
  cl  = OpenStudio::Model::Model
  ck1 = model.is_a?(cl)
  ck2 = boundary.respond_to?(:to_s)
  kva = false
  b   = ["Ground", "Foundation"]
  return mismatch("model"   , model   , cl    , mth, DBG, kva) unless ck1
  return mismatch("boundary", boundary, String, mth, DBG, kva) unless ck2

  boundary.capitalize!
  return invalid("boundary", mth, 2, DBG, kva) unless b.include?(boundary)

  # Reset surface KIVA-related objects.
  model.getSurfaces.each do |surface|
    kva = true unless surface.adjacentFoundation.empty?
    kva = true unless surface.surfacePropertyExposedFoundationPerimeter.empty?
    surface.resetAdjacentFoundation
    surface.resetSurfacePropertyExposedFoundationPerimeter
    next unless surface.isGroundSurface
    next     if surface.outsideBoundaryCondition.capitalize == boundary

    lc = surface.construction.empty? ? nil : surface.construction.get
    surface.setOutsideBoundaryCondition(boundary)
    next if boundary == "Ground"
    next if lc.nil?

    surface.setConstruction(lc) if surface.construction.empty?
  end

  perimeters = model.getSurfacePropertyExposedFoundationPerimeters

  kva = true unless perimeters.empty?

  # Remove KIVA exposed perimeters.
  perimeters.each { |perimeter| perimeter.remove }

  # Remove KIVA custom blocks, & foundations.
  model.getFoundationKivas.each do |kiva|
    kiva.removeAllCustomBlocks
    kiva.remove
  end

  log(INF, "Purged KIVA objects from model (#{mth})") if kva

  true
end

#truNormal(s = nil, r = 0) ⇒ Topolys::Vector3D?

Returns site (or true) Topolys normal vector of OpenStudio surface.

Parameters:

  • s (OpenStudio::Model::PlanarSurface) (defaults to: nil)

    a planar surface

  • r (#to_f) (defaults to: 0)

    a group/site rotation angle [0,2PI) radians

Returns:

  • (Topolys::Vector3D)

    true normal vector of s

  • (nil)

    if invalid input (see logs)



254
255
256
257
258
259
260
261
262
263
264
265
# File 'lib/tbd/geo.rb', line 254

def truNormal(s = nil, r = 0)
  mth = "TBD::#{__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)

  r = -r.to_f * Math::PI / 180.0
  vx = s.outwardNormal.x * Math.cos(r) - s.outwardNormal.y * Math.sin(r)
  vy = s.outwardNormal.x * Math.sin(r) + s.outwardNormal.y * Math.cos(r)
  vz = s.outwardNormal.z
  Topolys::Vector3D.new(vx, vy, vz)
end

#ua_md(ua = {}, lang = :en) ⇒ Array<String>

Generates MD-formatted, UA’ summary file.

option ua [#to_s] :objective ua[:objective] = “COMPLIANCE […]” option ua [#&] :details ua[:details] = “QC Energy Code […]” option ua [#to_s] :model “∑U•A + ∑PSI•L + ∑KHI•n […]” option ua [#key?] :b1 TB block of CONDITIONED spaces, ua[:b1] option ua [#key?] :b2 TB block of SEMIHEATED spaces, ua[:b2] option ua [#to_s] :descr user-provided project/summary description option ua [#to_s] :file OpenStudio file, e.g. “school23.osm” option ua [#to_s] :version OpenStudio SDK, e.g. “3.6.1” option ua [Time] :date time signature option ua [#to_s] :notes advisory info, ua[:notes] option ua [#key?] :areas binned areas (String), ua[:areas]

Parameters:

  • ua (#key?) (defaults to: {})

    preprocessed collection of UA-related strings

  • lang (#to_sym) (defaults to: :en)

    selected language, :en or :fr

Returns:

  • (Array<String>)

    MD-formatted strings (see logs if empty)



934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
# File 'lib/tbd/ua.rb', line 934

def ua_md(ua = {}, lang = :en)
  mth    = "TBD::#{__callee__}"
  report = []
  ck1    = ua.respond_to?(:key?)
  ck2    = lang.respond_to?(:to_sym)
  return mismatch(  "ua",   ua,   Hash, mth, DBG, report) unless ck1
  return mismatch("lang", lang, Symbol, mth, DBG, report) unless ck2

  lang = lang.to_sym
  return hashkey("language", ua, lang, mth, DBG, report) unless ua.key?(lang)
  return empty("ua"                  , mth, DBG, report)     if ua.empty?

  if ua[lang].key?(:objective) && ua[lang][:objective].respond_to?(:to_s)
    report << "# #{ua[lang][:objective].to_s}   "
    report << "   "
  end

  if ua[lang].key?(:details) && ua[lang][:details].respond_to?(:&)
    ua[lang][:details].each do |d|
      report << "#{d.to_s}   " if d.respond_to?(:to_s)
    end

    report << "   "
  end

  if ua.key?(:model) && ua[:model].respond_to?(:to_s)
    report << "##### SUMMARY   "   if lang == :en
    report << "##### SOMMAIRE   "  if lang == :fr
    report << "   "
    report << "#{ua[:model].to_s}   "
    report << "   "
  end

  if ua[lang].key?(:b1) && ua[lang][:b1].key?(:summary)
    last = ua[lang][:b1].keys.to_a.last
    report << "* #{ua[lang][:b1][:summary]}"

    ua[lang][:b1].each do |k, v|
      next                     if k == :summary
      report << "  * #{v}" unless k == last
      report << "  * #{v}   "  if k == last
      report << "   "          if k == last
    end
    report << "   "
  end

  if ua[lang].key?(:b2) && ua[lang][:b2].key?(:summary)
    last = ua[lang][:b2].keys.to_a.last
    report << "* #{ua[lang][:b2][:summary]}"

    ua[lang][:b2].each do |k, v|
      next                      if k == :summary
      report << "  * #{v}"  unless k == last
      report << "  * #{v}   "   if k == last
      report << "   "           if k == last
    end
    report << "   "
  end

  if ua.key?(:date)
    report << "##### DESCRIPTION   "
    report << "   "
    report << "* project : #{ua[:descr]}" if ua.key?(:descr) && lang == :en
    report << "* projet : #{ua[:descr]}"  if ua.key?(:descr) && lang == :fr
    model  = ""
    model  = "* model : #{ua[:file]}"     if ua.key?(:file)  && lang == :en
    model  = "* modèle : #{ua[:file]}"    if ua.key?(:file)  && lang == :fr
    model += " (v#{ua[:version]})"        if ua.key?(:version)
    report << model                   unless model.empty?
    report << "* TBD : v3.4.3"
    report << "* date : #{ua[:date]}"

    if lang == :en
      report << "* status : #{msg(status)}" unless status.zero?
      report << "* status : success !"          if status.zero?
    elsif lang == :fr
      report << "* statut : #{msg(status)}" unless status.zero?
      report << "* statut : succès !"           if status.zero?
    end
    report << "   "
  end

  if ua[lang].key?(:areas)
    report << "##### AREAS   " if lang == :en
    report << "##### AIRES   " if lang == :fr
    report << "   "
    ok = ua[lang][:areas].key?(:walls)
    report << "* #{ua[lang][:areas][:walls]}"  if ok
    ok = ua[lang][:areas].key?(:roofs)
    report << "* #{ua[lang][:areas][:roofs]}"  if ok
    ok = ua[lang][:areas].key?(:floors)
    report << "* #{ua[lang][:areas][:floors]}" if ok
    report << "   "
  end

  if ua[lang].key?(:notes)
    report << "##### NOTES   "
    report << "   "
    report << "#{ua[lang][:notes]}   "
    report << "   "
  end

  report
end

#ua_summary(date = Time.now, argh = {}) ⇒ Hash

Generates multilingual UA’ summary.

Parameters:

  • date (Time) (defaults to: Time.now)

    Time stamp

  • argh (Hash) (defaults to: {})

    TBD arguments

Options Hash (argh):

  • :seed (#to_s)

    OpenStudio file, e.g. “school23.osm”

  • :ua_ref (#to_s)

    reference ruleset e.g. “code (Quebec)”

  • :surfaces (Hash)

    set of TBD surfaces (see )

  • :version (#to_s)

    OpenStudio SDK, e.g. “3.6.1”

  • :io (Hash)

    TBD input/output variables (see TBD JSON schema)

Returns:

  • (Hash)

    binned values for UA’ (see logs if empty)



527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
# File 'lib/tbd/ua.rb', line 527

def ua_summary(date = Time.now, argh = {})
  mth = "TBD::#{__callee__}"
  cl1 = Time
  cl2 = String
  cl3 = Hash
  ua  = {}
  return mismatch("date", date, cl1, mth, DBG, ua) unless date.is_a?(cl1)
  return mismatch("argh", argh, cl3, mth, DBG, ua) unless argh.is_a?(cl3)

  argh[:seed    ] = ""  unless argh.key?(:seed)
  argh[:ua_ref  ] = ""  unless argh.key?(:ua_ref)
  argh[:surfaces] = nil unless argh.key?(:surfaces)
  argh[:version ] = ""  unless argh.key?(:version)
  argh[:io      ] = {}  unless argh.key?(:io)

  file = argh[:seed    ]
  ref  = argh[:ua_ref  ]
  s    = argh[:surfaces]
  v    = argh[:version ]
  io   = argh[:io      ]
  return mismatch(    "seed", file, cl2, mth, DBG, ua) unless file.is_a?(cl2)
  return mismatch( "UA' ref",  ref, cl2, mth, DBG, ua) unless ref.is_a?(cl2)
  return mismatch( "version",    v, cl2, mth, DBG, ua) unless v.is_a?(cl2)
  return mismatch("surfaces",    s, cl3, mth, DBG, ua) unless s.is_a?(cl3)
  return mismatch(      "io",   io, cl3, mth, DBG, ua) unless io.is_a?(cl3)
  return empty(   "surfaces",            mth, WRN, ua)     if s.empty?

  argh[:io][:description] = ""  unless argh[:io].key?(:description)
  descr = argh[:io][:description]

  ua[:descr  ] = ""
  ua[:file   ] = ""
  ua[:version] = ""
  ua[:model  ] = "∑U•A + ∑PSI•L + ∑KHI•n"
  ua[:date   ] = date
  ua[:descr  ] = descr unless descr.nil? || descr.empty?
  ua[:file   ] = file  unless file.nil?  || file.empty?
  ua[:version] = v     unless v.nil?     || v.empty?

  [:en, :fr].each { |lang| ua[lang] = {} }

  ua[:en][:notes] = "Automated assessment from the OpenStudio Measure, "\
    "Thermal Bridging and Derating (TBD). Open source and MIT-licensed, "\
    "TBD is provided as is (without warranty). Procedures are documented "\
    "in the source code: https://github.com/rd2/tbd. "

  ua[:fr][:notes] = "Analyse automatisée à partir de la measure "\
    "OpenStudio, 'Thermal Bridging and Derating' (ou TBD). Distribuée "\
    "librement (licence MIT), TBD est offerte telle quelle (sans "\
    "garantie). L'approche est documentée au sein du code source : "\
    "https://github.com/rd2/tbd."

  walls  = { net: 0, gross: 0, subs: 0 }
  roofs  = { net: 0, gross: 0, subs: 0 }
  floors = { net: 0, gross: 0, subs: 0 }
  areas  = { walls: walls, roofs: roofs, floors: floors }
  has    = {}
  val    = {}
  psi    = PSI.new

  unless ref.empty?
    shorts = psi.shorthands(ref)
    empty  = shorts[:has].empty? && shorts[:val].empty?
    has    = shorts[:has]                      unless empty
    val    = shorts[:val]                      unless empty
    log(ERR, "Invalid UA' reference set (#{mth})") if empty

    unless empty
      ua[:model] += " : Design vs '#{ref}'"

      case ref
      when "code (Quebec)"
        ua[:en][:objective] = "COMPLIANCE ASSESSMENT"
        ua[:en][:details  ] = []
        ua[:en][:details  ] << "Quebec Construction Code, Chapter I.1"
        ua[:en][:details  ] << "NECB 2015, modified version (2020)"
        ua[:en][:details  ] << "Division B, Section 3.3"
        ua[:en][:details  ] << "Building Envelope Trade-off Path"

        ua[:en][:notes] << " Calculations comply with Section 3.3 "\
          "requirements. Results are based on user input not subject to "\
          "prior validation (see DESCRIPTION), and as such the assessment "\
          "shall not be considered as a certification of compliance."

        ua[:fr][:objective] = "ANALYSE DE CONFORMITÉ"
        ua[:fr][:details  ] = []
        ua[:fr][:details  ] << "Code de construction du Québec, Chapitre I.1"
        ua[:fr][:details  ] << "CNÉB 2015, version modifiée (2020)"
        ua[:fr][:details  ] << "Division B, Section 3.3"
        ua[:fr][:details  ] << "Méthode des solutions de remplacement"

        ua[:fr][:notes] << " Les calculs sont conformes aux dispositions "\
          "de la Section 3.3. Les résultats sont tributaires d'intrants "\
          "fournis par l'utilisateur, sans validation préalable (voir "\
          "DESCRIPTION). Ce document ne peut constituer une attestation de "\
          "conformité."
      else
        ua[:en][:objective] = "UA'"
        ua[:fr][:objective] = "UA'"
      end
    end
  end

  # Set up 2x heating setpoint (HSTP) "blocks" (or bins):
  #   bloc1: spaces/zones with HSTP >= 18°C
  #   bloc2: spaces/zones with HSTP < 18°C
  #   (ref: 2021 Quebec energy code 3.3. UA' trade-off methodology)
  #   (... can be extended in the future to cover other standards)
  #
  # Determine UA' compliance separately for (i) bloc1 & (ii) bloc2.
  #
  # Each block's UA' = ∑ U•area + ∑ PSI•length + ∑ KHI•count
  blc = { walls:   0, roofs:     0, floors:    0, doors:     0,
          windows: 0, skylights: 0, rimjoists: 0, parapets:  0,
          trim:    0, corners:   0, balconies: 0, grade:     0,
          other:   0 } # party edges, expansion joints, spandrel edges, etc.

  b1       = {}
  b2       = {}
  b1[:pro] = blc       #  proposed design
  b1[:ref] = blc.clone #        reference
  b2[:pro] = blc.clone #  proposed design
  b2[:ref] = blc.clone #        reference

  # Loop through surfaces, subsurfaces and edges and populate bloc1 & bloc2.
  s.each do |id, surface|
    next unless surface.key?(:deratable)
    next unless surface[:deratable]
    next unless surface.key?(:type)

    type = surface[:type]
    next unless [:wall, :ceiling, :floor].include?(type)
    next unless surface.key?(:net)
    next unless surface[:net] > TOL
    next unless surface.key?(:u)
    next unless surface[:u] > TOL

    heating   = 21.0
    heating   = surface[:heating] if surface.key?(:heating)
    bloc      = b1
    bloc      = b2 if heating < 18
    reference = surface.key?(:ref)

    if type == :wall
      areas[:walls][:net ] += surface[:net]
      bloc[:pro][:walls  ] += surface[:net] * surface[:u  ]
      bloc[:ref][:walls  ] += surface[:net] * surface[:ref]     if reference
      bloc[:ref][:walls  ] += surface[:net] * surface[:u  ] unless reference
    elsif type == :ceiling
      areas[:roofs][:net ] += surface[:net]
      bloc[:pro][:roofs  ] += surface[:net] * surface[:u  ]
      bloc[:ref][:roofs  ] += surface[:net] * surface[:ref]     if reference
      bloc[:ref][:roofs  ] += surface[:net] * surface[:u  ] unless reference
    else
      areas[:floors][:net] += surface[:net]
      bloc[:pro][:floors ] += surface[:net] * surface[:u  ]
      bloc[:ref][:floors ] += surface[:net] * surface[:ref]     if reference
      bloc[:ref][:floors ] += surface[:net] * surface[:u  ] unless reference
    end

    [:doors, :windows, :skylights].each do |subs|
      next unless surface.key?(subs)

      surface[subs].values.each do |sub|
        next unless sub.key?(:gross)
        next unless sub.key?(:u    )
        next unless sub[:gross] > TOL
        next unless sub[:u    ] > TOL

        gross  = sub[:gross]
        gross *= sub[:mult ]                           if sub.key?(:mult)
        areas[:walls ][:subs] += gross                 if type == :wall
        areas[:roofs ][:subs] += gross                 if type == :ceiling
        areas[:floors][:subs] += gross                 if type == :floor
        bloc[:pro    ][subs ] += gross * sub[:u  ]
        bloc[:ref    ][subs ] += gross * sub[:ref]     if sub.key?(:ref)
        bloc[:ref    ][subs ] += gross * sub[:u  ] unless sub.key?(:ref)
      end
    end

    if surface.key?(:edges)
      surface[:edges].values.each do |edge|
        next unless edge.key?(:type)
        next unless edge.key?(:length)
        next unless edge[:length] > TOL
        next unless edge.key?(:psi)

        loss = edge[:length] * edge[:psi]
        type = edge[:type].to_s.downcase

        if edge[:type].to_s.downcase.include?("balcony")
          bloc[:pro][:balconies] += loss
        elsif edge[:type].to_s.downcase.include?("door")
          bloc[:pro][:trim     ] += loss
        elsif edge[:type].to_s.downcase.include?("skylight")
          bloc[:pro][:trim     ] += loss
        elsif edge[:type].to_s.downcase.include?("fenestration")
          bloc[:pro][:trim     ] += loss
        elsif edge[:type].to_s.downcase.include?("head")
          bloc[:pro][:trim     ] += loss
        elsif edge[:type].to_s.downcase.include?("sill")
          bloc[:pro][:trim     ] += loss
        elsif edge[:type].to_s.downcase.include?("jamb")
          bloc[:pro][:trim     ] += loss
        elsif edge[:type].to_s.downcase.include?("rimjoist")
          bloc[:pro][:rimjoists] += loss
        elsif edge[:type].to_s.downcase.include?("parapet")
          bloc[:pro][:parapets ] += loss
        elsif edge[:type].to_s.downcase.include?("roof")
          bloc[:pro][:parapets ] += loss
        elsif edge[:type].to_s.downcase.include?("corner")
          bloc[:pro][:corners  ] += loss
        elsif edge[:type].to_s.downcase.include?("grade")
          bloc[:pro][:grade    ] += loss
        else
          bloc[:pro][:other    ] += loss
        end

        next if val.empty?
        next if ref.empty?

        safer = psi.safe(ref, edge[:type])
        ok    = edge.key?(:ref)
        loss  = edge[:length] * edge[:ref]                    if ok
        loss  = edge[:length] * val[safer] * edge[:ratio] unless ok

        if edge[:type].to_s.downcase.include?("balcony")
          bloc[:ref][:balconies] += loss
        elsif edge[:type].to_s.downcase.include?("door")
          bloc[:ref][:trim     ] += loss
        elsif edge[:type].to_s.downcase.include?("skylight")
          bloc[:ref][:trim     ] += loss
        elsif edge[:type].to_s.downcase.include?("fenestration")
          bloc[:ref][:trim     ] += loss
        elsif edge[:type].to_s.downcase.include?("head")
          bloc[:ref][:trim     ] += loss
        elsif edge[:type].to_s.downcase.include?("sill")
          bloc[:ref][:trim     ] += loss
        elsif edge[:type].to_s.downcase.include?("jamb")
          bloc[:ref][:trim     ] += loss
        elsif edge[:type].to_s.downcase.include?("rimjoist")
          bloc[:ref][:rimjoists] += loss
        elsif edge[:type].to_s.downcase.include?("parapet")
          bloc[:ref][:parapets ] += loss
        elsif edge[:type].to_s.downcase.include?("roof")
          bloc[:ref][:parapets ] += loss
        elsif edge[:type].to_s.downcase.include?("corner")
          bloc[:ref][:corners  ] += loss
        elsif edge[:type].to_s.downcase.include?("grade")
          bloc[:ref][:grade    ] += loss
        else
          bloc[:ref][:other    ] += loss
        end
      end
    end

    if surface.key?(:pts)
      surface[:pts].values.each do |pts|
        next unless pts.key?(:val)
        next unless pts.key?(:n)

        bloc[:pro][:other] += pts[:val] * pts[:n]
        next unless pts.key?(:ref)

        bloc[:ref][:other] += pts[:ref] * pts[:n]
      end
    end
  end

  [:en, :fr].each do |lang|
    blc = [:b1, :b2]

    blc.each do |b|
      bloc    = b1
      bloc    = b2 if b == :b2
      pro_sum = bloc[:pro].values.sum
      ref_sum = bloc[:ref].values.sum

      if pro_sum > TOL || ref_sum > TOL
        ratio = nil
        ratio = (100.0 * (pro_sum - ref_sum) / ref_sum).abs if ref_sum > TOL
        str   = format("%.1f W/K (vs %.1f W/K)", pro_sum, ref_sum)
        str  += format(" +%.1f%%", ratio) if ratio && pro_sum > ref_sum # **
        str  += format(" -%.1f%%", ratio) if ratio && pro_sum < ref_sum
        ua[lang][b] = {}

        if b == :b1
          ua[:en][b][:summary] = "heated : #{str}"       if lang == :en
          ua[:fr][b][:summary] = "chauffé : #{str}"      if lang == :fr
        else
          ua[:en][b][:summary] = "semi-heated : #{str}"  if lang == :en
          ua[:fr][b][:summary] = "semi-chauffé : #{str}" if lang == :fr
        end

        bloc[:pro].each do |k, v|
          rf = bloc[:ref][k]
          next if v < TOL && rf < TOL
          ratio = nil
          ratio = (100.0 * (v - rf) / rf).abs            if rf > TOL
          str   = format("%.1f W/K (vs %.1f W/K)", v, rf)
          str  += format(" +%.1f%%", ratio)              if ratio && v > rf
          str  += format(" -%.1f%%", ratio)              if ratio && v < rf

          case k
          when :walls
            ua[:en][b][k] = "walls : #{str}"             if lang == :en
            ua[:fr][b][k] = "murs : #{str}"              if lang == :fr
          when :roofs
            ua[:en][b][k] = "roofs : #{str}"             if lang == :en
            ua[:fr][b][k] = "toits : #{str}"             if lang == :fr
          when :floors
            ua[:en][b][k] = "floors : #{str}"            if lang == :en
            ua[:fr][b][k] = "planchers : #{str}"         if lang == :fr
          when :doors
            ua[:en][b][k] = "doors : #{str}"             if lang == :en
            ua[:fr][b][k] = "portes : #{str}"            if lang == :fr
          when :windows
            ua[:en][b][k] = "windows : #{str}"           if lang == :en
            ua[:fr][b][k] = "fenêtres : #{str}"          if lang == :fr
          when :skylights
            ua[:en][b][k] = "skylights : #{str}"         if lang == :en
            ua[:fr][b][k] = "lanterneaux : #{str}"       if lang == :fr
          when :rimjoists
            ua[:en][b][k] = "rimjoists : #{str}"         if lang == :en
            ua[:fr][b][k] = "rives : #{str}"             if lang == :fr
          when :parapets
            ua[:en][b][k] = "parapets : #{str}"          if lang == :en
            ua[:fr][b][k] = "parapets : #{str}"          if lang == :fr
          when :trim
            ua[:en][b][k] = "trim : #{str}"              if lang == :en
            ua[:fr][b][k] = "chassis : #{str}"           if lang == :fr
          when :corners
            ua[:en][b][k] = "corners : #{str}"           if lang == :en
            ua[:fr][b][k] = "coins : #{str}"             if lang == :fr
          when :balconies
            ua[:en][b][k] = "balconies : #{str}"         if lang == :en
            ua[:fr][b][k] = "balcons : #{str}"           if lang == :fr
          when :grade
            ua[:en][b][k] = "grade : #{str}"             if lang == :en
            ua[:fr][b][k] = "tracé : #{str}"             if lang == :fr
          else
            ua[:en][b][k] = "other : #{str}"             if lang == :en
            ua[:fr][b][k] = "autres : #{str}"            if lang == :fr
          end
        end

        # Deterministic sorting.
        ua[lang][b][:summary] = ua[lang][b].delete(:summary)

        ua[lang][b].keys.each { |k| ua[lang][b][k] = ua[lang][b].delete(k) }
      end
    end
  end

  # Areas (m2).
  areas[:walls ][:gross] = areas[:walls ][:net] + areas[:walls ][:subs]
  areas[:roofs ][:gross] = areas[:roofs ][:net] + areas[:roofs ][:subs]
  areas[:floors][:gross] = areas[:floors][:net] + areas[:floors][:subs]

  ua[:en][:areas] = {}
  ua[:fr][:areas] = {}

  str  = format("walls : %.1f m2 (net)", areas[:walls][:net])
  str += format(", %.1f m2 (gross)", areas[:walls][:gross])
  ua[:en][:areas][:walls]  = str unless areas[:walls ][:gross] < TOL

  str  = format("roofs : %.1f m2 (net)", areas[:roofs][:net])
  str += format(", %.1f m2 (gross)", areas[:roofs][:gross])
  ua[:en][:areas][:roofs]  = str unless areas[:roofs ][:gross] < TOL

  str  = format("floors : %.1f m2 (net)", areas[:floors][:net])
  str += format(", %.1f m2 (gross)", areas[:floors][:gross])
  ua[:en][:areas][:floors] = str unless areas[:floors][:gross] < TOL

  str  = format("murs : %.1f m2 (net)", areas[:walls][:net])
  str += format(", %.1f m2 (brut)", areas[:walls][:gross])
  ua[:fr][:areas][:walls]  = str unless areas[:walls ][:gross] < TOL

  str  = format("toits : %.1f m2 (net)", areas[:roofs][:net])
  str += format(", %.1f m2 (brut)", areas[:roofs][:gross])
  ua[:fr][:areas][:roofs]  = str unless areas[:roofs ][:gross] < TOL

  str  = format("planchers : %.1f m2 (net)", areas[:floors][:net])
  str += format(", %.1f m2 (brut)", areas[:floors][:gross])
  ua[:fr][:areas][:floors] = str unless areas[:floors][:gross] < TOL

  ua
end

#uo(model = nil, lc = nil, id = "", hloss = 0.0, film = 0.0, ut = 0.0) ⇒ Hash

Calculates construction Uo (including surface film resistances) to meet Ut.

Parameters:

  • model (OpenStudio::Model::Model) (defaults to: nil)

    a model

  • lc (OpenStudio::Model::LayeredConstruction) (defaults to: nil)

    a layered construction

  • id (#to_s) (defaults to: "")

    layered construction identifier

  • hloss (Numeric) (defaults to: 0.0)

    heat loss from major thermal bridging, in W/K

  • film (Numeric) (defaults to: 0.0)

    target surface film resistance, in m2•K/W

  • ut (Numeric) (defaults to: 0.0)

    target overall Ut for lc, in W/m2•K

Returns:

  • (Hash)

    uo: lc Uo [W/m2•K] to meet Ut, m: uprated lc layer

  • (Hash)

    uo: (nil), m: (nil) if invalid input (see logs)



36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
# File 'lib/tbd/ua.rb', line 36

def uo(model = nil, lc = nil, id = "", hloss = 0.0, film = 0.0, ut = 0.0)
  mth = "TBD::#{__callee__}"
  res = { uo: nil, m: nil }
  cl1 = OpenStudio::Model::Model
  cl2 = OpenStudio::Model::LayeredConstruction
  cl3 = Numeric
  cl4 = String
  id  = trim(id)
  return mismatch("model", model, cl1, mth, DBG, res) unless model.is_a?(cl1)
  return mismatch("id"   ,    id, cl4, mth, DBG, res)     if id.empty?
  return mismatch("lc"   ,    lc, cl2, mth, DBG, res) unless lc.is_a?(cl2)
  return mismatch("hloss", hloss, cl3, mth, DBG, res) unless hloss.is_a?(cl3)
  return mismatch("film" ,  film, cl3, mth, DBG, res) unless film.is_a?(cl3)
  return mismatch("Ut"   ,    ut, cl3, mth, DBG, res) unless ut.is_a?(cl3)

  loss        = 0.0 # residual heatloss (not assigned) [W/K]
  area        = lc.getNetArea
  lyr         = insulatingLayer(lc)
  lyr[:index] = nil unless lyr[:index].is_a?(Numeric)
  lyr[:index] = nil unless lyr[:index] >= 0
  lyr[:index] = nil unless lyr[:index] < lc.layers.size
  return invalid("#{id} layer index", mth, 3, ERR, res) unless lyr[:index]
  return zero("#{id}: heatloss"     , mth,    WRN, res) unless hloss > TOL
  return zero("#{id}: films"        , mth,    WRN, res) unless film  > TOL
  return zero("#{id}: Ut"           , mth,    WRN, res) unless ut    > TOL
  return invalid("#{id}: Ut"        , mth, 6, WRN, res) unless ut    < 5.678
  return zero("#{id}: net area (m2)", mth,    ERR, res) unless area  > TOL

  # First, calculate initial layer RSi to initially meet Ut target.
  rt     = 1 / ut              # target construction Rt
  ro     = rsi(lc, film)       # current construction Ro
  new_r  = lyr[:r] + (rt - ro) # new, un-derated layer RSi
  new_u  = 1 / new_r

  # Then, uprate (if possible) to counter expected thermal bridging effects.
  u_psi  = hloss / area        # from psi+khi
  new_u -= u_psi               # uprated layer USi to counter psi+khi
  new_r  = 1 / new_u           # uprated layer RSi to counter psi+khi
  return zero("#{id}: new Rsi", mth, ERR, res) unless new_r > 0.001

  if lyr[:type] == :massless
    m     = lc.getLayer(lyr[:index]).to_MasslessOpaqueMaterial
    return  invalid("#{id} massless layer?", mth, 0, DBG, res) if m.empty?

    m     = m.get.clone(model).to_MasslessOpaqueMaterial.get
            m.setName("#{id} uprated")
    new_r = 0.001                      unless new_r > 0.001
    loss  = (new_u - 1 / new_r) * area unless new_r > 0.001
            m.setThermalResistance(new_r)
  else # type == :standard
    m     = lc.getLayer(lyr[:index]).to_StandardOpaqueMaterial
    return  invalid("#{id} standard layer?", mth, 0, DBG, res) if m.empty?

    m     = m.get.clone(model).to_StandardOpaqueMaterial.get
            m.setName("#{id} uprated")
    k     = m.thermalConductivity

    if new_r > 0.001
      d   = new_r * k

      unless d > 0.003
        d    = 0.003
        k    = d / new_r
        k    = 3.0                    unless k < 3.0
        loss = (new_u - k / d) * area unless k < 3.0
      end
    else # new_r < 0.001 m2•K/W
      d    = 0.001 * k
      d    = 0.003     unless d > 0.003
      k    = d / 0.001 unless d > 0.003
      loss = (new_u - k / d) * area
    end

    if m.setThickness(d)
      m.setThermalConductivity(k)
    else
      return invalid("Can't uprate #{id}: #{d} > 3m", mth, 0, ERR, res)
    end
  end

  return invalid("Can't ID insulating layer", mth, 0, ERR, res) unless m

  lc.setLayer(lyr[:index], m)
  uo = 1 / rsi(lc, film)

  if loss > TOL
    h_loss = format "%.3f", loss
    return invalid("Can't assign #{h_loss} W/K to #{id}", mth, 0, ERR, res)
  end

  res[:uo] = uo
  res[:m ] = m

  res
end

#uprate(model = nil, s = {}, argh = {}) ⇒ Bool, false

Uprates insulation layer of construction, based on user-selected Ut (argh).

Parameters:

  • model (OpenStudio::Model::Model) (defaults to: nil)

    a model

  • s (Hash) (defaults to: {})

    preprocessed collection of TBD surfaces

  • argh (Hash) (defaults to: {})

    TBD arguments

Options Hash (s):

  • :type (:wall, :ceiling, :floor)

    surface type

  • :deratable (Bool)

    whether surface can be thermally bridged

  • :construction (OpenStudio::LayeredConstruction)

    construction

  • :index (#to_i)

    deratable construction layer index

  • :ltype (:massless, :standard)

    indexed layer type

  • :filmRSI (#to_f)

    air film resistances (optional)

  • :r (#to_f)

    thermal resistance (RSI) of indexed layer

Options Hash (argh):

  • :uprate_walls (Bool) — default: false

    whether to uprate walls

  • :uprate_roofs (Bool) — default: false

    whether to uprate roofs

  • :uprate_floors (Bool) — default: false

    whether to uprate floors

  • :wall_ut (#to_f) — default: 5.678

    uprated wall Usi-factor target

  • :roof_ut (#to_f) — default: 5.678

    uprated roof Usi-factor target

  • :floor_ut (#to_f) — default: 5.678

    uprated floor Usi-factor target

  • :wall_option (#to_s) — default: "") construction to uprate (or "all"
  • :roof_option (#to_s) — default: "") construction to uprate (or "all"
  • :floor_option (#to_s) — default: "") construction to uprate (or "all"

Returns:

  • (Bool)

    whether successfully uprated

  • (false)

    if invalid input (see logs)



157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
# File 'lib/tbd/ua.rb', line 157

def uprate(model = nil, s = {}, argh = {})
  mth    = "TBD::#{__callee__}"
  cl1    = OpenStudio::Model::Model
  cl2    = Hash
  cl3    = OpenStudio::Model::LayeredConstruction
  tout   = []
  tout  << "all wall constructions"
  tout  << "all roof constructions"
  tout  << "all floor constructions"
  a      = false
  groups = { wall: {}, roof: {}, floor: {} }
  return mismatch("model"   , model, cl1, mth, DBG, a) unless model.is_a?(cl1)
  return mismatch("surfaces",     s, cl2, mth, DBG, a) unless s.is_a?(cl2)
  return mismatch("argh"    , model, cl1, mth, DBG, a) unless argh.is_a?(cl2)

  argh[:uprate_walls ] = false unless argh.key?(:uprate_walls)
  argh[:uprate_roofs ] = false unless argh.key?(:uprate_roofs)
  argh[:uprate_floors] = false unless argh.key?(:uprate_floors)
  argh[:wall_ut      ] = 5.678 unless argh.key?(:wall_ut)
  argh[:roof_ut      ] = 5.678 unless argh.key?(:roof_ut)
  argh[:floor_ut     ] = 5.678 unless argh.key?(:floor_ut)
  argh[:wall_option  ] = ""    unless argh.key?(:wall_option)
  argh[:roof_option  ] = ""    unless argh.key?(:roof_option)
  argh[:floor_option ] = ""    unless argh.key?(:floor_option)

  argh[:wall_option  ] = trim(argh[:wall_option ])
  argh[:roof_option  ] = trim(argh[:roof_option ])
  argh[:floor_option ] = trim(argh[:floor_option])

  groups[:wall ][:up] = argh[:uprate_walls ]
  groups[:roof ][:up] = argh[:uprate_roofs ]
  groups[:floor][:up] = argh[:uprate_floors]
  groups[:wall ][:ut] = argh[:wall_ut      ]
  groups[:roof ][:ut] = argh[:roof_ut      ]
  groups[:floor][:ut] = argh[:floor_ut     ]

  groups[:wall ][:op] = trim(argh[:wall_option  ])
  groups[:roof ][:op] = trim(argh[:roof_option  ])
  groups[:floor][:op] = trim(argh[:floor_option ])

  groups.each do |type, g|
    next unless g[:up]
    next unless g[:ut].is_a?(Numeric)
    next unless g[:ut] < 5.678
    next     if g[:ut] < 0

    typ  = type
    typ  = :ceiling if typ == :roof
    coll = {}
    area = 0
    film = 100000000000000
    lc   = nil
    id   = ""
    op   = g[:op].downcase
    all  = tout.include?(op)

    if g[:op].empty?
      log(ERR, "Construction (#{type}) to uprate? (#{mth})")
    elsif all
      s.each do |nom, surface|
        next unless surface.key?(:deratable   )
        next unless surface.key?(:type        )
        next unless surface.key?(:construction)
        next unless surface.key?(:filmRSI     )
        next unless surface.key?(:index       )
        next unless surface.key?(:ltype       )
        next unless surface.key?(:r           )
        next unless surface[:deratable   ]
        next unless surface[:type        ] == typ
        next unless surface[:construction].is_a?(cl3)
        next     if surface[:index       ].nil?

        # Retain lowest surface film resistance (e.g. tilted surfaces).
        c    = surface[:construction]
        i    = c.nameString
        aire = c.getNetArea
        film = surface[:filmRSI] if surface[:filmRSI] < film

        # Retain construction covering largest area. The following conditional
        # is reliable UNLESS linked to other deratable surface types e.g. both
        # floors AND walls (see "elsif lc" corrections below).
        if aire > area
          lc   = c
          area = aire
          id   = i
        end

        coll[i] = { area: aire, lc: c, s: {} }  unless coll.key?(i)
        coll[i][:s][nom] = { a: surface[:net] } unless coll[i][:s].key?(nom)
      end
    else
      id = g[:op]
      lc = model.getConstructionByName(id)
      log(ERR, "Construction '#{id}'? (#{mth})")         if lc.empty?
      next                                               if lc.empty?

      lc = lc.get.to_LayeredConstruction
      log(ERR, "'#{id}' layered construction? (#{mth})") if lc.empty?
      next                                               if lc.empty?

      lc       = lc.get
      area     = lc.getNetArea
      coll[id] = { area: area, lc: lc, s: {} }

      s.each do |nom, surface|
        next unless surface.key?(:deratable   )
        next unless surface.key?(:type        )
        next unless surface.key?(:construction)
        next unless surface.key?(:filmRSI     )
        next unless surface.key?(:index       )
        next unless surface.key?(:ltype       )
        next unless surface.key?(:r           )
        next unless surface[:deratable   ]
        next unless surface[:type        ] == typ
        next unless surface[:construction].is_a?(cl3)
        next     if surface[:index       ].nil?

        i = surface[:construction].nameString
        next unless i == id

        # Retain lowest surface film resistance (e.g. tilted surfaces).
        film = surface[:filmRSI] if surface[:filmRSI] < film

        coll[i][:s][nom] = { a: surface[:net] } unless coll[i][:s].key?(nom)
      end
    end

    if coll.empty?
      log(ERR, "No #{type} construction to uprate - skipping (#{mth})")
      next
    elsif lc
      # Valid layered construction - good to uprate!
      lyr         = insulatingLayer(lc)
      lyr[:index] = nil unless lyr[:index].is_a?(Numeric)
      lyr[:index] = nil unless lyr[:index] >= 0
      lyr[:index] = nil unless lyr[:index] < lc.layers.size

      log(ERR, "Insulation index for '#{id}'? (#{mth})") unless lyr[:index]
      next                                               unless lyr[:index]

      # Ensure lc is exclusively linked to deratable surfaces of right type.
      # If not, assign new lc clone to non-targeted surfaces.
      s.each do |nom, surface|
        next unless surface.key?(:type        )
        next unless surface.key?(:deratable   )
        next unless surface.key?(:construction)
        next unless surface[:construction].is_a?(cl3)
        next unless surface[:construction] == lc

        ok = true
        ok = false unless surface[:type     ] == typ
        ok = false unless surface[:deratable]
        ok = false unless coll.key?(id)
        ok = false unless coll[id][:s].key?(nom)

        unless ok
          log(WRN, "Cloning '#{nom}' construction - not '#{id}' (#{mth})")
          sss = model.getSurfaceByName(nom)
          next if sss.empty?

          sss    = sss.get
          cloned = lc.clone(model).to_LayeredConstruction.get
          cloned.setName("#{nom} - cloned")
          sss.setConstruction(cloned)
          surface[:construction] = cloned
          coll[id][:s].delete(nom)
        end
      end

      hloss = 0 # sum of applicable psi+khi-related losses [W/K]

      # Tally applicable psi+khi losses. Possible construction reassignment.
      coll.each do |i, col|
        col[:s].keys.each do |nom|
          next unless s.key?(nom)
          next unless s[nom].key?(:construction)
          next unless s[nom].key?(:index)
          next unless s[nom].key?(:ltype)
          next unless s[nom].key?(:r)

          # Tally applicable psi+khi.
          hloss += s[nom][:heatloss    ] if s[nom].key?(:heatloss)
          next  if s[nom][:construction] == lc

          # Reassign construction unless referencing lc.
          sss = model.getSurfaceByName(nom)
          next if sss.empty?

          sss = sss.get

          if sss.isConstructionDefaulted
            set = defaultConstructionSet(sss) # building? story?
            constructions = set.defaultExteriorSurfaceConstructions

            unless constructions.empty?
              constructions = constructions.get
              constructions.setWallConstruction(lc)        if typ == :wall
              constructions.setFloorConstruction(lc)       if typ == :floor
              constructions.setRoofCeilingConstruction(lc) if typ == :ceiling
            end
          else
            sss.setConstruction(lc)
          end

          s[nom][:construction] = lc          # reset TBD attributes
          s[nom][:index       ] = lyr[:index]
          s[nom][:ltype       ] = lyr[:type ]
          s[nom][:r           ] = lyr[:r    ] # temporary
        end
      end

      # Merge to ensure a single entry for coll Hash.
      coll.each do |i, col|
        next if i == id

        col[:s].each do |nom, sss|
          coll[id][:s][nom] = sss unless coll[id][:s].key?(nom)
        end
      end

      coll.delete_if { |i, _| i != id }

      unless coll.size == 1
        log(DBG, "Collection == 1? for '#{id}' (#{mth})")
        next
      end

      coll[id][:area] = lc.getNetArea
      res = uo(model, lc, id, hloss, film, g[:ut])

      unless res[:uo] && res[:m]
        log(ERR, "Unable to uprate '#{id}' (#{mth})")
        next
      end

      lyr = insulatingLayer(lc)

      # Loop through coll :s, and reset :r - likely modified by uo().
      coll.values.first[:s].keys.each do |nom|
        next unless s.key?(nom)
        next unless s[nom].key?(:index)
        next unless s[nom].key?(:ltype)
        next unless s[nom].key?(:r    )
        next unless s[nom][:index] == lyr[:index]
        next unless s[nom][:ltype] == lyr[:type ]

        s[nom][:r] = lyr[:r] # uprated insulating RSi factor, before derating
      end

      argh[:wall_uo ] = res[:uo] if typ == :wall
      argh[:roof_uo ] = res[:uo] if typ == :ceiling
      argh[:floor_uo] = res[:uo] if typ == :floor
    else
      log(ERR, "Nilled construction to uprate - (#{mth})")
      return false
    end
  end

  true
end