feat: Update room cooling load to accept either total or sensible loads, instead of requiring a total load.

This commit is contained in:
2026-02-05 09:44:37 -05:00
parent 5f03056534
commit 6a764ade2b
14 changed files with 210 additions and 130 deletions

View File

@@ -87,8 +87,7 @@ extension Room.Create {
return .init(
name: name,
heatingLoad: heatingLoad,
coolingTotal: coolingTotal,
coolingSensible: coolingSensible,
coolingLoad: coolingLoad,
registerCount: registerCount,
projectID: projectID
)
@@ -104,8 +103,7 @@ extension Room {
.id()
.field("name", .string, .required)
.field("heatingLoad", .double, .required)
.field("coolingTotal", .double, .required)
.field("coolingSensible", .double)
.field("coolingLoad", .dictionary, .required)
.field("registerCount", .int8, .required)
.field("rectangularSizes", .array)
.field("createdAt", .datetime)
@@ -136,11 +134,8 @@ final class RoomModel: Model, @unchecked Sendable, Validatable {
@Field(key: "heatingLoad")
var heatingLoad: Double
@Field(key: "coolingTotal")
var coolingTotal: Double
@Field(key: "coolingSensible")
var coolingSensible: Double?
@Field(key: "coolingLoad")
var coolingLoad: Room.CoolingLoad
@Field(key: "registerCount")
var registerCount: Int
@@ -163,8 +158,7 @@ final class RoomModel: Model, @unchecked Sendable, Validatable {
id: UUID? = nil,
name: String,
heatingLoad: Double,
coolingTotal: Double,
coolingSensible: Double? = nil,
coolingLoad: Room.CoolingLoad,
registerCount: Int,
rectangularSizes: [Room.RectangularSize]? = nil,
createdAt: Date? = nil,
@@ -174,8 +168,7 @@ final class RoomModel: Model, @unchecked Sendable, Validatable {
self.id = id
self.name = name
self.heatingLoad = heatingLoad
self.coolingTotal = coolingTotal
self.coolingSensible = coolingSensible
self.coolingLoad = coolingLoad
self.registerCount = registerCount
self.rectangularSizes = rectangularSizes
self.createdAt = createdAt
@@ -189,8 +182,7 @@ final class RoomModel: Model, @unchecked Sendable, Validatable {
projectID: $project.id,
name: name,
heatingLoad: heatingLoad,
coolingTotal: coolingTotal,
coolingSensible: coolingSensible,
coolingLoad: coolingLoad,
registerCount: registerCount,
rectangularSizes: rectangularSizes,
createdAt: createdAt!,
@@ -206,11 +198,8 @@ final class RoomModel: Model, @unchecked Sendable, Validatable {
if let heatingLoad = updates.heatingLoad, heatingLoad != self.heatingLoad {
self.heatingLoad = heatingLoad
}
if let coolingTotal = updates.coolingTotal, coolingTotal != self.coolingTotal {
self.coolingTotal = coolingTotal
}
if let coolingSensible = updates.coolingSensible, coolingSensible != self.coolingSensible {
self.coolingSensible = coolingSensible
if let coolingLoad = updates.coolingLoad, coolingLoad != self.coolingLoad {
self.coolingLoad = coolingLoad
}
if let registerCount = updates.registerCount, registerCount != self.registerCount {
self.registerCount = registerCount
@@ -229,11 +218,8 @@ final class RoomModel: Model, @unchecked Sendable, Validatable {
Validator.validate(\.heatingLoad, with: .greaterThanOrEquals(0))
.errorLabel("Heating Load", inline: true)
Validator.validate(\.coolingTotal, with: .greaterThanOrEquals(0))
.errorLabel("Cooling Total", inline: true)
Validator.validate(\.coolingSensible, with: Double.greaterThanOrEquals(0).optional())
.errorLabel("Cooling Sensible", inline: true)
Validator.validate(\.coolingLoad)
.errorLabel("Cooling Load", inline: true)
Validator.validate(\.registerCount, with: .greaterThanOrEquals(1))
.errorLabel("Register Count", inline: true)
@@ -244,6 +230,25 @@ final class RoomModel: Model, @unchecked Sendable, Validatable {
}
}
extension Room.CoolingLoad: Validatable {
public var body: some Validation<Self> {
Validator.accumulating {
// Ensure that at least one of the values is not nil.
Validator.oneOf {
Validator.validate(\.total, with: .notNil())
.errorLabel("Total or Sensible", inline: true)
Validator.validate(\.sensible, with: .notNil())
.errorLabel("Total or Sensible", inline: true)
}
Validator.validate(\.total, with: Double.greaterThan(0).optional())
Validator.validate(\.sensible, with: Double.greaterThan(0).optional())
}
}
}
extension Room.RectangularSize: Validatable {
public var body: some Validation<Self> {

View File

@@ -3,13 +3,12 @@ import ManualDCore
extension Room {
var heatingLoadPerRegister: Double {
public var heatingLoadPerRegister: Double {
heatingLoad / Double(registerCount)
}
func coolingSensiblePerRegister(projectSHR: Double) -> Double {
let sensible = coolingSensible ?? (coolingTotal * projectSHR)
public func coolingSensiblePerRegister(projectSHR: Double) throws -> Double {
let sensible = try coolingLoad.ensured(shr: projectSHR).sensible
return sensible / Double(registerCount)
}
}
@@ -30,8 +29,8 @@ extension TrunkSize.RoomProxy {
room.heatingLoadPerRegister * Double(actualRegisterCount)
}
func totalCoolingSensible(projectSHR: Double) -> Double {
room.coolingSensiblePerRegister(projectSHR: projectSHR) * Double(actualRegisterCount)
func totalCoolingSensible(projectSHR: Double) throws -> Double {
try room.coolingSensiblePerRegister(projectSHR: projectSHR) * Double(actualRegisterCount)
}
}
@@ -41,8 +40,8 @@ extension TrunkSize {
rooms.reduce(into: 0) { $0 += $1.totalHeatingLoad }
}
func totalCoolingSensible(projectSHR: Double) -> Double {
rooms.reduce(into: 0) { $0 += $1.totalCoolingSensible(projectSHR: projectSHR) }
func totalCoolingSensible(projectSHR: Double) throws -> Double {
try rooms.reduce(into: 0) { $0 += try $1.totalCoolingSensible(projectSHR: projectSHR) }
}
}

View File

@@ -1,13 +1,14 @@
import Foundation
// import Foundation
public struct CoolingLoad: Codable, Equatable, Sendable {
public let total: Double
public let sensible: Double
public var latent: Double { total - sensible }
public var shr: Double { sensible / total }
public init(total: Double, sensible: Double) {
self.total = total
self.sensible = sensible
}
}
//
// public struct CoolingLoad: Codable, Equatable, Sendable {
// public let total: Double
// public let sensible: Double
// public var latent: Double { total - sensible }
// public var shr: Double { sensible / total }
//
// public init(total: Double, sensible: Double) {
// self.total = total
// self.sensible = sensible
// }
// }

View File

@@ -152,11 +152,12 @@ extension DuctSizes {
public static func mock(
equipmentInfo: EquipmentInfo,
rooms: [Room],
trunks: [TrunkSize]
trunks: [TrunkSize],
shr: Double
) -> Self {
let totalHeatingLoad = rooms.totalHeatingLoad
let totalCoolingLoad = rooms.totalCoolingLoad
let totalCoolingLoad = try! rooms.totalCoolingLoad(shr: shr)
let roomContainers = rooms.reduce(into: [RoomContainer]()) { array, room in
array += RoomContainer.mock(
@@ -164,7 +165,8 @@ extension DuctSizes {
totalHeatingLoad: totalHeatingLoad,
totalCoolingLoad: totalCoolingLoad,
totalHeatingCFM: Double(equipmentInfo.heatingCFM),
totalCoolingCFM: Double(equipmentInfo.coolingCFM)
totalCoolingCFM: Double(equipmentInfo.coolingCFM),
shr: shr
)
}
@@ -187,14 +189,15 @@ extension DuctSizes {
totalHeatingLoad: Double,
totalCoolingLoad: Double,
totalHeatingCFM: Double,
totalCoolingCFM: Double
totalCoolingCFM: Double,
shr: Double
) -> [Self] {
var retval = [DuctSizes.RoomContainer]()
let heatingLoad = room.heatingLoad / Double(room.registerCount)
let heatingFraction = heatingLoad / totalHeatingLoad
let heatingCFM = totalHeatingCFM * heatingFraction
// Not really accurate, but works for mocks.
let coolingLoad = room.coolingTotal / Double(room.registerCount)
let coolingLoad = (try! room.coolingLoad.ensured(shr: shr).total) / Double(room.registerCount)
let coolingFraction = coolingLoad / totalCoolingLoad
let coolingCFM = totalCoolingCFM * coolingFraction

View File

@@ -9,19 +9,19 @@ import Foundation
public struct Room: Codable, Equatable, Identifiable, Sendable {
/// The unique id of the room.
public let id: UUID
/// The project this room is associated with.
public let projectID: Project.ID
/// A unique name for the room in the project.
public let name: String
/// The heating load required for the room (from Manual-J).
public let heatingLoad: Double
/// The total cooling load required for the room (from Manual-J).
public let coolingTotal: Double
/// An optional sensible cooling load for the room.
///
/// **NOTE:** This is generally not set, but calculated from the project wide
/// sensible heat ratio.
public let coolingSensible: Double?
/// The cooling load required for the room (from Manual-J).
public let coolingLoad: CoolingLoad
/// The number of registers for the room.
public let registerCount: Int
/// The rectangular duct size calculations for a room.
@@ -29,8 +29,10 @@ public struct Room: Codable, Equatable, Identifiable, Sendable {
/// **NOTE:** These are optionally set after the round sizes have been calculate
/// for a room.
public let rectangularSizes: [RectangularSize]?
/// When the room was created in the database.
public let createdAt: Date
/// When the room was updated in the database.
public let updatedAt: Date
@@ -39,8 +41,7 @@ public struct Room: Codable, Equatable, Identifiable, Sendable {
projectID: Project.ID,
name: String,
heatingLoad: Double,
coolingTotal: Double,
coolingSensible: Double? = nil,
coolingLoad: CoolingLoad,
registerCount: Int = 1,
rectangularSizes: [RectangularSize]? = nil,
createdAt: Date,
@@ -50,13 +51,46 @@ public struct Room: Codable, Equatable, Identifiable, Sendable {
self.projectID = projectID
self.name = name
self.heatingLoad = heatingLoad
self.coolingTotal = coolingTotal
self.coolingSensible = coolingSensible
self.coolingLoad = coolingLoad
self.registerCount = registerCount
self.rectangularSizes = rectangularSizes
self.createdAt = createdAt
self.updatedAt = updatedAt
}
/// Represents the cooling load of a room.
///
/// Generally only one of the values is provided by a Manual-J room x room
/// calculation.
///
public struct CoolingLoad: Codable, Equatable, Sendable {
public let total: Double?
public let sensible: Double?
public init(total: Double? = nil, sensible: Double? = nil) {
self.total = total
self.sensible = sensible
}
/// Calculates the cooling load based on the shr.
///
/// Generally Manual-J room x room loads provide either the total load or the
/// sensible load, so this allows us to calculate whichever is not provided.
public func ensured(shr: Double) throws -> (total: Double, sensible: Double) {
switch (total, sensible) {
case (.none, .none):
throw CoolingLoadError("Both the total and sensible loads are nil.")
case (.some(let total), .some(let sensible)):
return (total, sensible)
case (.some(let total), .none):
return (total, total * shr)
case (.none, .some(let sensible)):
return (sensible / shr, sensible)
}
}
}
}
extension Room {
@@ -69,17 +103,21 @@ extension Room {
/// The heating load required for the room (from Manual-J).
public let heatingLoad: Double
/// The total cooling load required for the room (from Manual-J).
public let coolingTotal: Double
public let coolingTotal: Double?
/// An optional sensible cooling load for the room.
public let coolingSensible: Double?
/// The number of registers for the room.
public let registerCount: Int
public var coolingLoad: Room.CoolingLoad {
.init(total: coolingTotal, sensible: coolingSensible)
}
public init(
projectID: Project.ID,
name: String,
heatingLoad: Double,
coolingTotal: Double,
coolingTotal: Double? = nil,
coolingSensible: Double? = nil,
registerCount: Int = 1
) {
@@ -118,7 +156,7 @@ extension Room {
/// Represents field that can be updated on a room after it's been created in the database.
///
/// Only fields that are supplied get updated.
/// Onlly fields that are supplied get updated.
public struct Update: Codable, Equatable, Sendable {
/// A unique name for the room in the project.
public let name: String?
@@ -133,6 +171,13 @@ extension Room {
/// The rectangular duct size calculations for a room.
public let rectangularSizes: [RectangularSize]?
public var coolingLoad: CoolingLoad? {
guard coolingTotal != nil || coolingSensible != nil else {
return nil
}
return .init(total: coolingTotal, sensible: coolingSensible)
}
public init(
name: String? = nil,
heatingLoad: Double? = nil,
@@ -169,22 +214,30 @@ extension Array where Element == Room {
}
/// The sum of total cooling loads for an array of rooms.
public var totalCoolingLoad: Double {
reduce(into: 0) { $0 += $1.coolingTotal }
public func totalCoolingLoad(shr: Double) throws -> Double {
try reduce(into: 0) { $0 += try $1.coolingLoad.ensured(shr: shr).total }
}
/// The sum of sensible cooling loads for an array of rooms.
///
/// - Parameters:
/// - shr: The project wide sensible heat ratio.
public func totalCoolingSensible(shr: Double) -> Double {
reduce(into: 0) {
let sensible = $1.coolingSensible ?? ($1.coolingTotal * shr)
$0 += sensible
public func totalCoolingSensible(shr: Double) throws -> Double {
try reduce(into: 0) {
// let sensible = $1.coolingSensible ?? ($1.coolingTotal * shr)
$0 += try $1.coolingLoad.ensured(shr: shr).sensible
}
}
}
public struct CoolingLoadError: Error, Equatable, Sendable {
public let reason: String
public init(_ reason: String) {
self.reason = reason
}
}
#if DEBUG
extension Room {
@@ -199,8 +252,8 @@ extension Array where Element == Room {
projectID: projectID,
name: "Bed-1",
heatingLoad: 3913,
coolingTotal: 2472,
coolingSensible: nil,
coolingLoad: .init(total: 2472),
// coolingSensible: nil,
registerCount: 1,
rectangularSizes: nil,
createdAt: now,
@@ -211,8 +264,8 @@ extension Array where Element == Room {
projectID: projectID,
name: "Entry",
heatingLoad: 8284,
coolingTotal: 2916,
coolingSensible: nil,
coolingLoad: .init(total: 2916),
// coolingSensible: nil,
registerCount: 2,
rectangularSizes: nil,
createdAt: now,
@@ -223,8 +276,8 @@ extension Array where Element == Room {
projectID: projectID,
name: "Family Room",
heatingLoad: 9785,
coolingTotal: 7446,
coolingSensible: nil,
coolingLoad: .init(total: 7446),
// coolingSensible: nil,
registerCount: 3,
rectangularSizes: nil,
createdAt: now,
@@ -235,8 +288,8 @@ extension Array where Element == Room {
projectID: projectID,
name: "Kitchen",
heatingLoad: 4518,
coolingTotal: 5096,
coolingSensible: nil,
coolingLoad: .init(total: 5096),
// coolingSensible: nil,
registerCount: 2,
rectangularSizes: nil,
createdAt: now,
@@ -247,8 +300,8 @@ extension Array where Element == Room {
projectID: projectID,
name: "Living Room",
heatingLoad: 7553,
coolingTotal: 6829,
coolingSensible: nil,
coolingLoad: .init(total: 6829),
// coolingSensible: nil,
registerCount: 2,
rectangularSizes: nil,
createdAt: now,
@@ -259,8 +312,8 @@ extension Array where Element == Room {
projectID: projectID,
name: "Master",
heatingLoad: 8202,
coolingTotal: 2076,
coolingSensible: nil,
coolingLoad: .init(total: 2076),
// coolingSensible: nil,
registerCount: 2,
rectangularSizes: nil,
createdAt: now,

View File

@@ -218,9 +218,11 @@ extension SiteRoute.View.ProjectRoute {
Field("projectID") { Project.ID.parser() }
Field("name", .string)
Field("heatingLoad") { Double.parser() }
Field("coolingTotal") { Double.parser() }
Optionally {
Field("coolingSensible", default: nil) { Double.parser() }
Field("coolingTotal") { Double.parser() }
}
Optionally {
Field("coolingSensible") { Double.parser() }
}
Field("registerCount") { Digits() }
}

View File

@@ -150,7 +150,12 @@ extension PdfClient {
project: project,
rooms: rooms,
componentLosses: ComponentPressureLoss.mock(projectID: project.id),
ductSizes: .mock(equipmentInfo: equipmentInfo, rooms: rooms, trunks: trunks),
ductSizes: .mock(
equipmentInfo: equipmentInfo,
rooms: rooms,
trunks: trunks,
shr: project.sensibleHeatRatio ?? 0.83
),
equipmentInfo: equipmentInfo,
maxSupplyTEL: equivalentLengths.first { $0.type == .supply }!,
maxReturnTEL: equivalentLengths.first { $0.type == .return }!,

View File

@@ -21,10 +21,9 @@ struct RoomsTable: HTML, Sendable {
tr {
td { room.name }
td { room.heatingLoad.string(digits: 0) }
td { room.coolingTotal.string(digits: 0) }
td { try! room.coolingLoad.ensured(shr: projectSHR).total.string(digits: 0) }
td {
(room.coolingSensible
?? (room.coolingTotal * projectSHR)).string(digits: 0)
try! room.coolingLoad.ensured(shr: projectSHR).sensible.string(digits: 0)
}
td { room.registerCount.string() }
}
@@ -37,10 +36,10 @@ struct RoomsTable: HTML, Sendable {
rooms.totalHeatingLoad.string(digits: 0)
}
td(.class("coolingTotal label")) {
rooms.totalCoolingLoad.string(digits: 0)
try! rooms.totalCoolingLoad(shr: projectSHR).string(digits: 0)
}
td(.class("coolingSensible label")) {
rooms.totalCoolingSensible(shr: projectSHR).string(digits: 0)
try! rooms.totalCoolingSensible(shr: projectSHR).string(digits: 0)
}
td {}
}

View File

@@ -41,11 +41,11 @@ extension ManualDClient {
var retval: [DuctSizes.RoomContainer] = []
let totalHeatingLoad = rooms.totalHeatingLoad
let totalCoolingSensible = rooms.totalCoolingSensible(shr: sharedRequest.projectSHR)
let totalCoolingSensible = try rooms.totalCoolingSensible(shr: sharedRequest.projectSHR)
for room in rooms {
let heatingLoad = room.heatingLoadPerRegister
let coolingLoad = room.coolingSensiblePerRegister(projectSHR: sharedRequest.projectSHR)
let coolingLoad = try room.coolingSensiblePerRegister(projectSHR: sharedRequest.projectSHR)
let heatingPercent = heatingLoad / totalHeatingLoad
let coolingPercent = coolingLoad / totalCoolingSensible
let heatingCFM = heatingPercent * Double(sharedRequest.equipmentInfo.heatingCFM)
@@ -102,11 +102,11 @@ extension ManualDClient {
var retval = [DuctSizes.TrunkContainer]()
let totalHeatingLoad = rooms.totalHeatingLoad
let totalCoolingSensible = rooms.totalCoolingSensible(shr: sharedRequest.projectSHR)
let totalCoolingSensible = try rooms.totalCoolingSensible(shr: sharedRequest.projectSHR)
for trunk in trunks {
let heatingLoad = trunk.totalHeatingLoad
let coolingLoad = trunk.totalCoolingSensible(projectSHR: sharedRequest.projectSHR)
let coolingLoad = try trunk.totalCoolingSensible(projectSHR: sharedRequest.projectSHR)
let heatingPercent = heatingLoad / totalHeatingLoad
let coolingPercent = coolingLoad / totalCoolingSensible
let heatingCFM = heatingPercent * Double(sharedRequest.equipmentInfo.heatingCFM)
@@ -181,18 +181,18 @@ extension DuctSizes.SizeContainer {
}
}
extension Room {
var heatingLoadPerRegister: Double {
heatingLoad / Double(registerCount)
}
func coolingSensiblePerRegister(projectSHR: Double) -> Double {
let sensible = coolingSensible ?? (coolingTotal * projectSHR)
return sensible / Double(registerCount)
}
}
// extension Room {
//
// var heatingLoadPerRegister: Double {
//
// heatingLoad / Double(registerCount)
// }
//
// func coolingSensiblePerRegister(projectSHR: Double) -> Double {
// let sensible = coolingSensible ?? (coolingTotal * projectSHR)
// return sensible / Double(registerCount)
// }
// }
extension TrunkSize.RoomProxy {
@@ -210,8 +210,8 @@ extension TrunkSize.RoomProxy {
room.heatingLoadPerRegister * Double(actualRegisterCount)
}
func totalCoolingSensible(projectSHR: Double) -> Double {
room.coolingSensiblePerRegister(projectSHR: projectSHR) * Double(actualRegisterCount)
func totalCoolingSensible(projectSHR: Double) throws -> Double {
try room.coolingSensiblePerRegister(projectSHR: projectSHR) * Double(actualRegisterCount)
}
}
@@ -221,7 +221,7 @@ extension TrunkSize {
rooms.reduce(into: 0) { $0 += $1.totalHeatingLoad }
}
func totalCoolingSensible(projectSHR: Double) -> Double {
rooms.reduce(into: 0) { $0 += $1.totalCoolingSensible(projectSHR: projectSHR) }
func totalCoolingSensible(projectSHR: Double) throws -> Double {
try rooms.reduce(into: 0) { $0 += try $1.totalCoolingSensible(projectSHR: projectSHR) }
}
}

View File

@@ -73,14 +73,15 @@ struct RoomForm: HTML, Sendable {
.value(room?.heatingLoad)
)
// TODO: Add description that only one is required (cooling total or sensible)
LabeledInput(
"Cooling Total",
.name("coolingTotal"),
.type(.number),
.placeholder("1234"),
.required,
.placeholder("1234 (Optional)"),
.min("0"),
.value(room?.coolingTotal)
.value(room?.coolingLoad.total)
)
LabeledInput(
@@ -89,7 +90,7 @@ struct RoomForm: HTML, Sendable {
.type(.number),
.placeholder("1234 (Optional)"),
.min("0"),
.value(room?.coolingSensible)
.value(room?.coolingLoad.sensible)
)
LabeledInput(

View File

@@ -54,13 +54,15 @@ struct RoomsView: HTML, Sendable {
div(.class("flex justify-center items-end space-x-4 my-auto font-bold")) {
span(.class("text-lg")) { "Cooling Total" }
Badge(number: rooms.totalCoolingLoad, digits: 0)
// TODO: ResultView ??
Badge(number: try! rooms.totalCoolingLoad(shr: sensibleHeatRatio ?? 1.0), digits: 0)
.attributes(.class("badge-success"))
}
div(.class("flex grow justify-end items-end space-x-4 me-4 my-auto font-bold")) {
span(.class("text-lg")) { "Cooling Sensible" }
Badge(number: rooms.totalCoolingSensible(shr: sensibleHeatRatio ?? 1.0), digits: 0)
// TODO: ResultView ??
Badge(number: try! rooms.totalCoolingSensible(shr: sensibleHeatRatio ?? 1.0), digits: 0)
.attributes(.class("badge-info"))
}
}
@@ -125,10 +127,11 @@ struct RoomsView: HTML, Sendable {
let shr: Double
var coolingSensible: Double {
guard let value = room.coolingSensible else {
return room.coolingTotal * shr
}
return value
try! room.coolingLoad.ensured(shr: shr).sensible
// guard let value = room.coolingSensible else {
// return room.coolingTotal * shr
// }
// return value
}
init(room: Room, shr: Double?) {
@@ -147,7 +150,7 @@ struct RoomsView: HTML, Sendable {
}
td {
div(.class("flex justify-center")) {
Number(room.coolingTotal, digits: 0)
Number(try! room.coolingLoad.ensured(shr: shr).total, digits: 0)
// .attributes(.class("text-success"))
}
}