feat: Initial duct sizing view and calculations, need to add supply and return trunk sizing.

This commit is contained in:
2026-01-09 12:43:56 -05:00
parent 30fddb9dce
commit 7083178844
15 changed files with 402 additions and 15 deletions

View File

@@ -105,6 +105,7 @@ let package = Package(
name: "ViewController",
dependencies: [
.target(name: "DatabaseClient"),
.target(name: "ManualDClient"),
.target(name: "ManualDCore"),
.target(name: "Styleguide"),
.product(name: "Dependencies", package: "swift-dependencies"),

View File

@@ -2206,6 +2206,9 @@
.collapse {
visibility: collapse;
}
.invisible {
visibility: hidden;
}
.visible {
visibility: visible;
}
@@ -5360,6 +5363,9 @@
}
}
}
.my-1 {
margin-block: calc(var(--spacing) * 1);
}
.my-1\.5 {
margin-block: calc(var(--spacing) * 1.5);
}
@@ -7805,12 +7811,18 @@
.px-4 {
padding-inline: calc(var(--spacing) * 4);
}
.py-1 {
padding-block: calc(var(--spacing) * 1);
}
.py-1\.5 {
padding-block: calc(var(--spacing) * 1.5);
}
.py-2 {
padding-block: calc(var(--spacing) * 2);
}
.py-4 {
padding-block: calc(var(--spacing) * 4);
}
.ps-2 {
padding-inline-start: calc(var(--spacing) * 2);
}
@@ -9444,6 +9456,26 @@
}
}
}
.lg\:visible {
@media (width >= 64rem) {
visibility: visible;
}
}
.lg\:block {
@media (width >= 64rem) {
display: block;
}
}
.lg\:inline-block {
@media (width >= 64rem) {
display: inline-block;
}
}
.lg\:table-cell {
@media (width >= 64rem) {
display: table-cell;
}
}
.lg\:grid-cols-2 {
@media (width >= 64rem) {
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -9454,6 +9486,16 @@
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
.xl\:visible {
@media (width >= 80rem) {
visibility: visible;
}
}
.xl\:table-cell {
@media (width >= 80rem) {
display: table-cell;
}
}
.xl\:grid-cols-2 {
@media (width >= 80rem) {
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -9464,6 +9506,11 @@
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
.\32 xl\:table-cell {
@media (width >= 96rem) {
display: table-cell;
}
}
.dark\:text-white {
@media (prefers-color-scheme: dark) {
color: var(--color-white);

View File

@@ -1,6 +1,18 @@
import Foundation
import ManualDCore
extension Room {
var heatingLoadPerRegister: Double {
heatingLoad / Double(registerCount)
}
func coolingSensiblePerRegister(projectSHR: Double) -> Double {
let sensible = coolingSensible ?? (coolingTotal * projectSHR)
return sensible / Double(registerCount)
}
}
extension ComponentPressureLosses {
var totalLosses: Double { values.reduce(0) { $0 + $1 } }
}

View File

@@ -1,5 +1,6 @@
import Dependencies
import DependenciesMacros
import Logging
import ManualDCore
@DependencyClient
@@ -9,6 +10,57 @@ public struct ManualDClient: Sendable {
public var totalEffectiveLength: @Sendable (TotalEffectiveLengthRequest) async throws -> Int
public var equivalentRectangularDuct:
@Sendable (EquivalentRectangularDuctRequest) async throws -> EquivalentRectangularDuctResponse
public func calculateSizes(
rooms: [Room],
equipmentInfo: EquipmentInfo,
maxSupplyLength: EffectiveLength,
maxReturnLength: EffectiveLength,
designFrictionRate: Double,
projectSHR: Double,
logger: Logger? = nil
) async throws -> [DuctSizing.RoomContainer] {
var registerIDCount = 1
var retval: [DuctSizing.RoomContainer] = []
let totalHeatingLoad = rooms.totalHeatingLoad
let totalCoolingSensible = rooms.totalCoolingSensible(shr: projectSHR)
for room in rooms {
let heatingLoad = room.heatingLoadPerRegister
let coolingLoad = room.coolingSensiblePerRegister(projectSHR: projectSHR)
let heatingPercent = heatingLoad / totalHeatingLoad
let coolingPercent = coolingLoad / totalCoolingSensible
let heatingCFM = heatingPercent * Double(equipmentInfo.heatingCFM)
let coolingCFM = coolingPercent * Double(equipmentInfo.coolingCFM)
let designCFM = DuctSizing.DesignCFM(heating: heatingCFM, cooling: coolingCFM)
let sizes = try await self.ductSize(
.init(designCFM: Int(designCFM.value), frictionRate: designFrictionRate)
)
for n in 1...room.registerCount {
retval.append(
.init(
registerID: "SR-\(registerIDCount)",
roomID: room.id,
roomName: "\(room.name)-\(n)",
heatingLoad: heatingLoad,
coolingLoad: coolingLoad,
heatingCFM: heatingCFM,
coolingCFM: coolingCFM,
designCFM: designCFM,
roundSize: sizes.ductulatorSize,
finalSize: sizes.finalSize,
velocity: sizes.velocity,
flexSize: sizes.flexSize
)
)
registerIDCount += 1
}
}
return retval
}
}
extension ManualDClient: TestDependencyKey {

View File

@@ -0,0 +1,69 @@
import Dependencies
import Foundation
public enum DuctSizing {
public struct RoomContainer: Codable, Equatable, Sendable {
public let registerID: String
public let roomID: Room.ID
public let roomName: String
public let heatingLoad: Double
public let coolingLoad: Double
public let heatingCFM: Double
public let coolingCFM: Double
public let designCFM: DesignCFM
public let roundSize: Double
public let finalSize: Int
public let velocity: Int
public let flexSize: Int
public init(
registerID: String,
roomID: Room.ID,
roomName: String,
heatingLoad: Double,
coolingLoad: Double,
heatingCFM: Double,
coolingCFM: Double,
designCFM: DesignCFM,
roundSize: Double,
finalSize: Int,
velocity: Int,
flexSize: Int
) {
self.registerID = registerID
self.roomID = roomID
self.roomName = roomName
self.heatingLoad = heatingLoad
self.coolingLoad = coolingLoad
self.heatingCFM = heatingCFM
self.coolingCFM = coolingCFM
self.designCFM = designCFM
self.roundSize = roundSize
self.finalSize = finalSize
self.velocity = velocity
self.flexSize = flexSize
}
}
public enum DesignCFM: Codable, Equatable, Sendable {
case heating(Double)
case cooling(Double)
public init(heating: Double, cooling: Double) {
if heating >= cooling {
self = .heating(heating)
} else {
self = .cooling(cooling)
}
}
public var value: Double {
switch self {
case .heating(let value): return value
case .cooling(let value): return value
}
}
}
}

View File

@@ -85,6 +85,24 @@ extension Room {
}
}
extension Array where Element == Room {
public var totalHeatingLoad: Double {
reduce(into: 0) { $0 += $1.heatingLoad }
}
public var totalCoolingLoad: Double {
reduce(into: 0) { $0 += $1.coolingTotal }
}
public func totalCoolingSensible(shr: Double) -> Double {
reduce(into: 0) {
let sensible = $1.coolingSensible ?? ($1.coolingTotal * shr)
$0 += sensible
}
}
}
#if DEBUG
extension Room {

View File

@@ -152,6 +152,7 @@ extension SiteRoute.View.ProjectRoute {
public enum DetailRoute: Equatable, Sendable {
case index(tab: Tab = .default)
case componentLoss(ComponentLossRoute)
case ductSizing(DuctSizingRoute)
case equipment(EquipmentInfoRoute)
case equivalentLength(EquivalentLengthRoute)
case frictionRate(FrictionRateRoute)
@@ -169,6 +170,9 @@ extension SiteRoute.View.ProjectRoute {
Route(.case(Self.componentLoss)) {
ComponentLossRoute.router
}
Route(.case(Self.ductSizing)) {
DuctSizingRoute.router
}
Route(.case(Self.equipment)) {
EquipmentInfoRoute.router
}
@@ -670,6 +674,19 @@ extension SiteRoute.View.ProjectRoute {
}
}
public enum DuctSizingRoute: Equatable, Sendable {
case index
static let rootPath = "duct-sizing"
static let router = OneOf {
Route(.case(Self.index)) {
Path { rootPath }
Method.get
}
}
}
}
extension SiteRoute.View {

View File

@@ -22,6 +22,30 @@ extension DatabaseClient.Projects {
}
}
extension DatabaseClient {
func designFrictionRate(
projectID: Project.ID
) async throws -> (EquipmentInfo, EffectiveLength.MaxContainer, Double)? {
guard let equipmentInfo = try await equipment.fetch(projectID) else {
return nil
}
let equivalentLengths = try await effectiveLength.fetchMax(projectID)
guard let tel = equivalentLengths.total else { return nil }
let componentLosses = try await componentLoss.fetch(projectID)
guard componentLosses.count > 0 else { return nil }
let availableStaticPressure =
equipmentInfo.staticPressure - componentLosses.totalComponentPressureLoss
let designFrictionRate = (availableStaticPressure * 100) / tel
return (equipmentInfo, equivalentLengths, designFrictionRate)
}
}
extension DatabaseClient.ComponentLoss {
func createDefaults(projectID: Project.ID) async throws {

View File

@@ -0,0 +1,36 @@
import Logging
import ManualDClient
import ManualDCore
extension ManualDClient {
func calculate(
rooms: [Room],
designFrictionRateResult: (EquipmentInfo, EffectiveLength.MaxContainer, Double)?,
projectSHR: Double?,
logger: Logger? = nil
) async throws -> [DuctSizing.RoomContainer] {
guard let designFrictionRateResult else { return [] }
let equipmentInfo = designFrictionRateResult.0
let effectiveLengths = designFrictionRateResult.1
let designFrictionRate = designFrictionRateResult.2
guard let maxSupply = effectiveLengths.supply else { return [] }
guard let maxReturn = effectiveLengths.return else { return [] }
let ductRooms = try await self.calculateSizes(
rooms: rooms,
equipmentInfo: equipmentInfo,
maxSupplyLength: maxSupply,
maxReturnLength: maxReturn,
designFrictionRate: designFrictionRate,
projectSHR: projectSHR ?? 1.0,
logger: logger
)
logger?.debug("Rooms: \(ductRooms)")
return ductRooms
}
}

View File

@@ -117,6 +117,8 @@ extension SiteRoute.View.ProjectRoute {
}
case .componentLoss(let route):
return try await route.renderView(on: request, projectID: projectID)
case .ductSizing(let route):
return try await route.renderView(on: request, projectID: projectID)
case .equipment(let route):
return try await route.renderView(on: request, projectID: projectID)
case .equivalentLength(let route):
@@ -335,6 +337,20 @@ extension SiteRoute.View.ProjectRoute.EquivalentLengthRoute {
}
}
extension SiteRoute.View.ProjectRoute.DuctSizingRoute {
func renderView(on request: ViewController.Request, projectID: Project.ID) async throws
-> AnySendableHTML
{
switch self {
case .index:
return request.view {
ProjectView(projectID: projectID, activeTab: .ductSizing, logger: request.logger)
}
}
}
}
private func _render<C: HTML>(
isHtmxRequest: Bool,
active activeTab: SiteRoute.View.ProjectRoute.DetailRoute.Tab = .rooms,

View File

@@ -20,10 +20,6 @@ struct ComponentLossForm: HTML, Sendable {
for: .project(.detail(projectID, .componentLoss(.index)))
)
.appendingPath(componentLoss?.id)
// if let componentLoss {
// return baseRoute.appending("/\(componentLoss.id)")
// }
// return baseRoute
}
var body: some HTML {

View File

@@ -0,0 +1,81 @@
import Elementary
import ElementaryHTMX
import ManualDCore
import Styleguide
// TODO: Add error text if prior steps are not completed.
struct DuctSizingView: HTML, Sendable {
let rooms: [DuctSizing.RoomContainer]
var body: some HTML {
div {
h1(.class("text-2xl py-4")) { "Duct Sizes" }
div(.class("overflow-x-auto")) {
table(.class("table table-zebra")) {
thead {
tr(.class("text-xl text-gray-400 font-bold")) {
th { "ID" }
th { "Name" }
th { "H-BTU" }
th { "C-BTU" }
th(.class("hidden 2xl:table-cell")) { "Htg CFM" }
th(.class("hidden 2xl:table-cell")) { "Clg CFM" }
th { "Dsn CFM" }
th(.class("hidden xl:table-cell")) { "Round Size" }
th { "Velocity" }
th { "Final Size" }
th { "Flex Size" }
}
}
tbody {
for room in rooms {
RoomRow(room: room)
}
}
}
}
}
}
struct RoomRow: HTML, Sendable {
let room: DuctSizing.RoomContainer
var body: some HTML<HTMLTag.tr> {
tr(.class("text-lg")) {
td { room.registerID }
td { room.roomName }
td { Number(room.heatingLoad, digits: 0) }
td { Number(room.coolingLoad, digits: 0) }
td(.class("hidden 2xl:table-cell")) { Number(room.heatingCFM, digits: 0) }
td(.class("hidden 2xl:table-cell")) { Number(room.coolingCFM, digits: 0) }
td {
Number(room.designCFM.value, digits: 0)
.attributes(
.class("badge badge-outline badge-\(room.designCFM.color) text-xl font-bold"))
}
td(.class("hidden xl:table-cell")) { Number(room.roundSize, digits: 0) }
td { Number(room.velocity) }
td {
Number(room.finalSize)
.attributes(.class("badge badge-outline badge-secondary text-xl font-bold"))
}
td {
Number(room.flexSize)
.attributes(.class("badge badge-outline badge-primary text-xl font-bold"))
}
}
}
}
}
extension DuctSizing.DesignCFM {
var color: String {
switch self {
case .heating: return "error"
case .cooling: return "info"
}
}
}

View File

@@ -2,21 +2,27 @@ import DatabaseClient
import Dependencies
import Elementary
import ElementaryHTMX
import Logging
import ManualDClient
import ManualDCore
import Styleguide
struct ProjectView: HTML, Sendable {
@Dependency(\.database) var database
@Dependency(\.manualD) var manualD
let projectID: Project.ID
let activeTab: SiteRoute.View.ProjectRoute.DetailRoute.Tab
let logger: Logger?
init(
projectID: Project.ID,
activeTab: SiteRoute.View.ProjectRoute.DetailRoute.Tab
activeTab: SiteRoute.View.ProjectRoute.DetailRoute.Tab,
logger: Logger? = nil
) {
self.projectID = projectID
self.activeTab = activeTab
self.logger = logger
}
var body: some HTML {
@@ -61,7 +67,15 @@ struct ProjectView: HTML, Sendable {
projectID: projectID
)
case .ductSizing:
div { "FIX ME!" }
try await DuctSizingView(
rooms: manualD.calculate(
rooms: database.rooms.fetch(projectID),
designFrictionRateResult: database.designFrictionRate(projectID: projectID),
projectSHR: database.projects.getSensibleHeatRatio(projectID),
logger: logger
)
)
// div { "FIX ME!" }
}
}
@@ -178,7 +192,11 @@ extension ProjectView {
}
li(.class("w-full")) {
row(
title: "Duct Sizes", icon: .wind, href: "#", isComplete: false, hideIsComplete: true
title: "Duct Sizes",
icon: .wind,
route: .project(.detail(projectID, .ductSizing(.index))),
isComplete: false,
hideIsComplete: true
)
.attributes(.class("btn-active"), when: active == .ductSizing)
}

View File

@@ -8,20 +8,21 @@ import Styleguide
// TODO: Need to hold the project ID in hidden input field.
struct RoomForm: HTML, Sendable {
static let id = "roomForm"
static func id(_ room: Room? = nil) -> String {
let baseId = "roomForm"
guard let room else { return baseId }
return baseId.appending("_\(room.id.idString)")
}
let id: String
let dismiss: Bool
let projectID: Project.ID
let room: Room?
init(
id: String = Self.id,
dismiss: Bool,
projectID: Project.ID,
room: Room? = nil
) {
self.id = id
self.dismiss = dismiss
self.projectID = projectID
self.room = room
@@ -35,7 +36,7 @@ struct RoomForm: HTML, Sendable {
}
var body: some HTML {
ModalForm(id: id, dismiss: dismiss) {
ModalForm(id: Self.id(room), dismiss: dismiss) {
h1(.class("text-3xl font-bold pb-6")) { "Room" }
form(
.class("modal-backdrop"),

View File

@@ -22,7 +22,7 @@ struct RoomsView: HTML, Sendable {
) {
div(.class("flex me-4")) {
PlusButton()
.attributes(.showModal(id: RoomForm.id))
.attributes(.showModal(id: RoomForm.id()))
}
}
}
@@ -134,12 +134,11 @@ struct RoomsView: HTML, Sendable {
EditButton()
.attributes(
.class("join-item"),
.showModal(id: "roomForm_\(room.name)")
.showModal(id: RoomForm.id(room))
)
}
}
RoomForm(
id: "roomForm_\(room.name)",
dismiss: true,
projectID: room.projectID,
room: room