From 708317884444ebff05a7ca9009196534c3a00db1 Mon Sep 17 00:00:00 2001 From: Michael Housh Date: Fri, 9 Jan 2026 12:43:56 -0500 Subject: [PATCH] feat: Initial duct sizing view and calculations, need to add supply and return trunk sizing. --- Package.swift | 1 + Public/css/output.css | 47 +++++++++++ Sources/ManualDClient/Helpers.swift | 12 +++ Sources/ManualDClient/ManualDClient.swift | 52 ++++++++++++ Sources/ManualDCore/DuctSizing.swift | 69 ++++++++++++++++ Sources/ManualDCore/Room.swift | 18 +++++ Sources/ManualDCore/Routes/ViewRoute.swift | 17 ++++ .../Extensions/DatabaseExtensions.swift | 24 ++++++ .../Extensions/ManualDClient+extensions.swift | 36 +++++++++ Sources/ViewController/Live.swift | 16 ++++ .../ComponentLoss/ComponentLossForm.swift | 4 - .../Views/DuctSizing/DuctSizingView.swift | 81 +++++++++++++++++++ .../Views/Project/ProjectView.swift | 24 +++++- .../ViewController/Views/Rooms/RoomForm.swift | 11 +-- .../Views/Rooms/RoomsView.swift | 5 +- 15 files changed, 402 insertions(+), 15 deletions(-) create mode 100644 Sources/ManualDCore/DuctSizing.swift create mode 100644 Sources/ViewController/Extensions/ManualDClient+extensions.swift create mode 100644 Sources/ViewController/Views/DuctSizing/DuctSizingView.swift diff --git a/Package.swift b/Package.swift index 380ca9e..4a0d97e 100644 --- a/Package.swift +++ b/Package.swift @@ -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"), diff --git a/Public/css/output.css b/Public/css/output.css index 43a0cca..5e3f069 100644 --- a/Public/css/output.css +++ b/Public/css/output.css @@ -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); diff --git a/Sources/ManualDClient/Helpers.swift b/Sources/ManualDClient/Helpers.swift index bd2a95d..89b8561 100644 --- a/Sources/ManualDClient/Helpers.swift +++ b/Sources/ManualDClient/Helpers.swift @@ -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 } } } diff --git a/Sources/ManualDClient/ManualDClient.swift b/Sources/ManualDClient/ManualDClient.swift index ed31aee..adb193c 100644 --- a/Sources/ManualDClient/ManualDClient.swift +++ b/Sources/ManualDClient/ManualDClient.swift @@ -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 { diff --git a/Sources/ManualDCore/DuctSizing.swift b/Sources/ManualDCore/DuctSizing.swift new file mode 100644 index 0000000..52325d9 --- /dev/null +++ b/Sources/ManualDCore/DuctSizing.swift @@ -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 + } + } + } +} diff --git a/Sources/ManualDCore/Room.swift b/Sources/ManualDCore/Room.swift index 5b7147d..a19aec0 100644 --- a/Sources/ManualDCore/Room.swift +++ b/Sources/ManualDCore/Room.swift @@ -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 { diff --git a/Sources/ManualDCore/Routes/ViewRoute.swift b/Sources/ManualDCore/Routes/ViewRoute.swift index 944b263..dbb0766 100644 --- a/Sources/ManualDCore/Routes/ViewRoute.swift +++ b/Sources/ManualDCore/Routes/ViewRoute.swift @@ -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 { diff --git a/Sources/ViewController/Extensions/DatabaseExtensions.swift b/Sources/ViewController/Extensions/DatabaseExtensions.swift index f73bbb6..da8171b 100644 --- a/Sources/ViewController/Extensions/DatabaseExtensions.swift +++ b/Sources/ViewController/Extensions/DatabaseExtensions.swift @@ -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 { diff --git a/Sources/ViewController/Extensions/ManualDClient+extensions.swift b/Sources/ViewController/Extensions/ManualDClient+extensions.swift new file mode 100644 index 0000000..76879d1 --- /dev/null +++ b/Sources/ViewController/Extensions/ManualDClient+extensions.swift @@ -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 + + } +} diff --git a/Sources/ViewController/Live.swift b/Sources/ViewController/Live.swift index d9d5890..8f46dca 100644 --- a/Sources/ViewController/Live.swift +++ b/Sources/ViewController/Live.swift @@ -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( isHtmxRequest: Bool, active activeTab: SiteRoute.View.ProjectRoute.DetailRoute.Tab = .rooms, diff --git a/Sources/ViewController/Views/ComponentLoss/ComponentLossForm.swift b/Sources/ViewController/Views/ComponentLoss/ComponentLossForm.swift index fda17aa..fe3d28a 100644 --- a/Sources/ViewController/Views/ComponentLoss/ComponentLossForm.swift +++ b/Sources/ViewController/Views/ComponentLoss/ComponentLossForm.swift @@ -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 { diff --git a/Sources/ViewController/Views/DuctSizing/DuctSizingView.swift b/Sources/ViewController/Views/DuctSizing/DuctSizingView.swift new file mode 100644 index 0000000..8b23b39 --- /dev/null +++ b/Sources/ViewController/Views/DuctSizing/DuctSizingView.swift @@ -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 { + 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" + } + } +} diff --git a/Sources/ViewController/Views/Project/ProjectView.swift b/Sources/ViewController/Views/Project/ProjectView.swift index 9f74ed9..a39ef5a 100644 --- a/Sources/ViewController/Views/Project/ProjectView.swift +++ b/Sources/ViewController/Views/Project/ProjectView.swift @@ -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) } diff --git a/Sources/ViewController/Views/Rooms/RoomForm.swift b/Sources/ViewController/Views/Rooms/RoomForm.swift index 0f135f8..206aa78 100644 --- a/Sources/ViewController/Views/Rooms/RoomForm.swift +++ b/Sources/ViewController/Views/Rooms/RoomForm.swift @@ -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"), diff --git a/Sources/ViewController/Views/Rooms/RoomsView.swift b/Sources/ViewController/Views/Rooms/RoomsView.swift index db5e588..f7cb86f 100644 --- a/Sources/ViewController/Views/Rooms/RoomsView.swift +++ b/Sources/ViewController/Views/Rooms/RoomsView.swift @@ -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