WIP: Begin cleaning up duct sizing routes.

This commit is contained in:
2026-01-13 17:01:44 -05:00
parent 930db145a8
commit f990c4b6db
13 changed files with 634 additions and 30 deletions

View File

@@ -34,10 +34,11 @@ extension DatabaseClient.TrunkSizes: TestDependencyKey {
let model = try TrunkRoomModel(
trunkID: trunk.requireID(),
roomID: room.requireID(),
registers: registers
registers: registers,
type: request.type
)
try await model.save(on: database)
try roomProxies.append(model.toDTO())
try await roomProxies.append(model.toDTO(on: database))
}
return try .init(
@@ -54,16 +55,33 @@ extension DatabaseClient.TrunkSizes: TestDependencyKey {
try await model.delete(on: database)
},
fetch: { projectID in
try await TrunkModel.query(on: database)
.with(\.$rooms)
let models = try await TrunkModel.query(on: database)
.with(\.$project)
.with(\.$rooms)
.filter(\.$project.$id == projectID)
.all()
.map { try $0.toDTO() }
return try await withThrowingTaskGroup(of: DuctSizing.TrunkSize.self) { group in
for model in models {
group.addTask {
try await model.toDTO(on: database)
}
}
return try await group.reduce(into: [DuctSizing.TrunkSize]()) {
$0.append($1)
}
}
// return try await models.map {
// try await $0.toDTO(on: database)
// }
},
get: { id in
try await TrunkModel.find(id, on: database)
.map { try $0.toDTO() }
guard let model = try await TrunkModel.find(id, on: database) else {
return nil
}
return try await model.toDTO(on: database)
}
)
}
@@ -110,12 +128,14 @@ extension DuctSizing.TrunkSize {
try await database.schema(TrunkRoomModel.schema)
.id()
.field("registers", .array(of: .int), .required)
.field("type", .string, .required)
.field(
"trunkID", .uuid, .required, .references(TrunkModel.schema, "id", onDelete: .cascade)
)
.field(
"roomID", .uuid, .required, .references(RoomModel.schema, "id", onDelete: .cascade)
)
.unique(on: "trunkID", "roomID", "type")
.create()
}
@@ -143,22 +163,30 @@ final class TrunkRoomModel: Model, @unchecked Sendable {
@Field(key: "registers")
var registers: [Int]
@Field(key: "type")
var type: String
init() {}
init(
id: UUID? = nil,
trunkID: TrunkModel.IDValue,
roomID: RoomModel.IDValue,
registers: [Int]
registers: [Int],
type: DuctSizing.TrunkSize.TrunkType
) {
self.id = id
$trunk.id = trunkID
$room.id = roomID
self.registers = registers
self.type = type.rawValue
}
func toDTO() throws -> DuctSizing.TrunkSize.RoomProxy {
.init(
func toDTO(on database: any Database) async throws -> DuctSizing.TrunkSize.RoomProxy {
guard let room = try await RoomModel.find($room.id, on: database) else {
throw NotFoundError()
}
return .init(
room: try room.toDTO(),
registers: registers
)
@@ -199,12 +227,25 @@ final class TrunkModel: Model, @unchecked Sendable {
self.type = type.rawValue
}
func toDTO() throws -> DuctSizing.TrunkSize {
try .init(
func toDTO(on database: any Database) async throws -> DuctSizing.TrunkSize {
let rooms = try await withThrowingTaskGroup(of: DuctSizing.TrunkSize.RoomProxy.self) { group in
for room in self.rooms {
group.addTask {
try await room.toDTO(on: database)
}
}
return try await group.reduce(into: [DuctSizing.TrunkSize.RoomProxy]()) {
$0.append($1)
}
}
return try .init(
id: requireID(),
projectID: $project.id,
type: .init(rawValue: type)!,
rooms: rooms.map { try $0.toDTO() },
rooms: rooms,
height: height
)

View File

@@ -14,6 +14,28 @@ extension Room {
}
}
extension DuctSizing.TrunkSize.RoomProxy {
var totalHeatingLoad: Double {
room.heatingLoadPerRegister * Double(registers.count)
}
func totalCoolingSensible(projectSHR: Double) -> Double {
room.coolingSensiblePerRegister(projectSHR: projectSHR) * Double(registers.count)
}
}
extension DuctSizing.TrunkSize {
var totalHeatingLoad: Double {
rooms.reduce(into: 0) { $0 += $1.totalHeatingLoad }
}
func totalCoolingSensible(projectSHR: Double) -> Double {
rooms.reduce(into: 0) { $0 += $1.totalCoolingSensible(projectSHR: projectSHR) }
}
}
extension ComponentPressureLosses {
var totalLosses: Double { values.reduce(0) { $0 + $1 } }
}

View File

@@ -12,6 +12,29 @@ public struct ManualDClient: Sendable {
@Sendable (EquivalentRectangularDuctRequest) async throws -> EquivalentRectangularDuctResponse
public func calculateSizes(
rooms: [Room],
trunks: [DuctSizing.TrunkSize],
equipmentInfo: EquipmentInfo,
maxSupplyLength: EffectiveLength,
maxReturnLength: EffectiveLength,
designFrictionRate: Double,
projectSHR: Double,
logger: Logger? = nil
) async throws -> (rooms: [DuctSizing.RoomContainer], trunks: [DuctSizing.TrunkContainer]) {
try await (
calculateSizes(
rooms: rooms, equipmentInfo: equipmentInfo,
maxSupplyLength: maxSupplyLength, maxReturnLength: maxReturnLength,
designFrictionRate: designFrictionRate, projectSHR: projectSHR
),
calculateSizes(
rooms: rooms, trunks: trunks, equipmentInfo: equipmentInfo,
maxSupplyLength: maxSupplyLength, maxReturnLength: maxReturnLength,
designFrictionRate: designFrictionRate, projectSHR: projectSHR)
)
}
func calculateSizes(
rooms: [Room],
equipmentInfo: EquipmentInfo,
maxSupplyLength: EffectiveLength,
@@ -56,6 +79,7 @@ public struct ManualDClient: Sendable {
registerID: "SR-\(registerIDCount)",
roomID: room.id,
roomName: "\(room.name)-\(n)",
roomRegister: n,
heatingLoad: heatingLoad,
coolingLoad: coolingLoad,
heatingCFM: heatingCFM,
@@ -76,6 +100,56 @@ public struct ManualDClient: Sendable {
return retval
}
func calculateSizes(
rooms: [Room],
trunks: [DuctSizing.TrunkSize],
equipmentInfo: EquipmentInfo,
maxSupplyLength: EffectiveLength,
maxReturnLength: EffectiveLength,
designFrictionRate: Double,
projectSHR: Double,
logger: Logger? = nil
) async throws -> [DuctSizing.TrunkContainer] {
var retval = [DuctSizing.TrunkContainer]()
let totalHeatingLoad = rooms.totalHeatingLoad
let totalCoolingSensible = rooms.totalCoolingSensible(shr: projectSHR)
for trunk in trunks {
let heatingLoad = trunk.totalHeatingLoad
let coolingLoad = trunk.totalCoolingSensible(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)
)
var width: Int? = nil
if let height = trunk.height {
let rectangularSize = try await self.equivalentRectangularDuct(
.init(round: sizes.finalSize, height: height)
)
width = rectangularSize.width
}
retval.append(
.init(
trunk: trunk,
ductSize: .init(
designCFM: designCFM,
roundSize: sizes.ductulatorSize,
finalSize: sizes.finalSize,
velocity: sizes.velocity,
flexSize: sizes.flexSize)
)
)
}
return retval
}
}
extension ManualDClient: TestDependencyKey {

View File

@@ -21,11 +21,41 @@ public enum DuctSizing {
}
public struct SizeContainer: Codable, Equatable, Sendable {
public let designCFM: DesignCFM
public let roundSize: Double
public let finalSize: Int
public let velocity: Int
public let flexSize: Int
public let height: Int?
public let width: Int?
public init(
designCFM: DuctSizing.DesignCFM,
roundSize: Double,
finalSize: Int,
velocity: Int,
flexSize: Int,
height: Int? = nil,
width: Int? = nil
) {
self.designCFM = designCFM
self.roundSize = roundSize
self.finalSize = finalSize
self.velocity = velocity
self.flexSize = flexSize
self.height = height
self.width = width
}
}
public struct RoomContainer: Codable, Equatable, Sendable {
public let registerID: String
public let roomID: Room.ID
public let roomName: String
public let roomRegister: Int
public let heatingLoad: Double
public let coolingLoad: Double
public let heatingCFM: Double
@@ -42,6 +72,7 @@ public enum DuctSizing {
registerID: String,
roomID: Room.ID,
roomName: String,
roomRegister: Int,
heatingLoad: Double,
coolingLoad: Double,
heatingCFM: Double,
@@ -57,6 +88,7 @@ public enum DuctSizing {
self.registerID = registerID
self.roomID = roomID
self.roomName = roomName
self.roomRegister = roomRegister
self.heatingLoad = heatingLoad
self.coolingLoad = coolingLoad
self.heatingCFM = heatingCFM
@@ -94,6 +126,21 @@ public enum DuctSizing {
extension DuctSizing {
public struct TrunkContainer: Codable, Equatable, Identifiable, Sendable {
public var id: TrunkSize.ID { trunk.id }
public let trunk: TrunkSize
public let ductSize: SizeContainer
public init(
trunk: TrunkSize,
ductSize: SizeContainer
) {
self.trunk = trunk
self.ductSize = ductSize
}
}
public struct TrunkSize: Codable, Equatable, Identifiable, Sendable {
public let id: UUID
@@ -145,16 +192,18 @@ extension DuctSizing.TrunkSize {
public var id: Room.ID { room.id }
public let room: Room
public let registers: [Int]?
public let registers: [Int]
public init(room: Room, registers: [Int]? = nil) {
public init(room: Room, registers: [Int]) {
self.room = room
self.registers = registers
}
}
public enum TrunkType: String, Codable, Equatable, Sendable {
public enum TrunkType: String, CaseIterable, Codable, Equatable, Sendable {
case `return`
case supply
public static let allCases = [Self.supply, .return]
}
}

View File

@@ -609,7 +609,9 @@ extension SiteRoute.View.ProjectRoute {
case index
case deleteRectangularSize(Room.ID, DuctSizing.RectangularDuct.ID)
case roomRectangularForm(Room.ID, RoomRectangularForm)
case trunk(TrunkRoute)
public static let roomPath = "room"
static let rootPath = "duct-sizing"
static let router = OneOf {
@@ -620,7 +622,7 @@ extension SiteRoute.View.ProjectRoute {
Route(.case(Self.deleteRectangularSize)) {
Path {
rootPath
"room"
roomPath
Room.ID.parser()
}
Method.delete
@@ -631,7 +633,7 @@ extension SiteRoute.View.ProjectRoute {
Route(.case(Self.roomRectangularForm)) {
Path {
rootPath
"room"
roomPath
Room.ID.parser()
}
Method.post
@@ -646,6 +648,67 @@ extension SiteRoute.View.ProjectRoute {
.map(.memberwise(RoomRectangularForm.init))
}
}
Route(.case(Self.trunk)) {
Path { rootPath }
TrunkRoute.router
}
}
public enum TrunkRoute: Equatable, Sendable {
case delete(DuctSizing.TrunkSize.ID)
case submit(TrunkSizeForm)
case update(DuctSizing.TrunkSize.ID, TrunkSizeForm)
public static let rootPath = "trunk"
static let router = OneOf {
Route(.case(Self.delete)) {
Path {
rootPath
DuctSizing.TrunkSize.ID.parser()
}
Method.delete
}
Route(.case(Self.submit)) {
Path {
rootPath
}
Method.post
Body {
FormData {
Field("projectID") { Project.ID.parser() }
Field("type") { DuctSizing.TrunkSize.TrunkType.parser() }
Optionally {
Field("height") { Int.parser() }
}
Many {
Field("rooms", .string)
}
}
.map(.memberwise(TrunkSizeForm.init))
}
}
Route(.case(Self.update)) {
Path {
rootPath
DuctSizing.TrunkSize.ID.parser()
}
Method.patch
Body {
FormData {
Field("projectID") { Project.ID.parser() }
Field("type") { DuctSizing.TrunkSize.TrunkType.parser() }
Optionally {
Field("height") { Int.parser() }
}
Many {
Field("rooms", .string)
}
}
.map(.memberwise(TrunkSizeForm.init))
}
}
}
}
public struct RoomRectangularForm: Equatable, Sendable {
@@ -653,6 +716,13 @@ extension SiteRoute.View.ProjectRoute {
public let register: Int
public let height: Int
}
public struct TrunkSizeForm: Equatable, Sendable {
public let projectID: Project.ID
public let type: DuctSizing.TrunkSize.TrunkType
public let height: Int?
public let rooms: [String]
}
}
}

View File

@@ -26,11 +26,14 @@ extension DatabaseClient.Projects {
extension DatabaseClient {
func calculateDuctSizes(projectID: Project.ID) async throws -> [DuctSizing.RoomContainer] {
func calculateDuctSizes(
projectID: Project.ID
) async throws -> (rooms: [DuctSizing.RoomContainer], trunks: [DuctSizing.TrunkContainer]) {
@Dependency(\.manualD) var manualD
return try await manualD.calculate(
rooms: rooms.fetch(projectID),
trunks: trunkSizes.fetch(projectID),
designFrictionRateResult: designFrictionRate(projectID: projectID),
projectSHR: projects.getSensibleHeatRatio(projectID)
)

View File

@@ -6,20 +6,22 @@ extension ManualDClient {
func calculate(
rooms: [Room],
trunks: [DuctSizing.TrunkSize],
designFrictionRateResult: (EquipmentInfo, EffectiveLength.MaxContainer, Double)?,
projectSHR: Double?,
logger: Logger? = nil
) async throws -> [DuctSizing.RoomContainer] {
guard let designFrictionRateResult else { return [] }
) async throws -> (rooms: [DuctSizing.RoomContainer], trunks: [DuctSizing.TrunkContainer]) {
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 [] }
guard let maxSupply = effectiveLengths.supply else { return ([], []) }
guard let maxReturn = effectiveLengths.return else { return ([], []) }
let ductRooms = try await self.calculateSizes(
rooms: rooms,
trunks: trunks,
equipmentInfo: equipmentInfo,
maxSupplyLength: maxSupply,
maxReturnLength: maxReturn,

View File

@@ -0,0 +1,44 @@
import Foundation
import Logging
import ManualDCore
extension SiteRoute.View.ProjectRoute.DuctSizingRoute.TrunkSizeForm {
func toCreate(logger: Logger? = nil) throws -> DuctSizing.TrunkSize.Create {
try .init(
projectID: projectID,
type: type,
rooms: makeRooms(logger: logger),
height: height
)
}
func makeRooms(logger: Logger?) throws -> [Room.ID: [Int]] {
var retval = [Room.ID: [Int]]()
for room in rooms {
let split = room.split(separator: "_")
guard let idString = split.first,
let id = UUID(uuidString: String(idString))
else {
logger?.error("Could not parse id from: \(room)")
throw RoomError()
}
guard let registerString = split.last,
let register = Int(registerString)
else {
logger?.error("Could not register number from: \(room)")
throw RoomError()
}
if var currRegisters = retval[id] {
currRegisters.append(register)
retval[id] = currRegisters
} else {
retval[id] = [register]
}
}
return retval
}
}
struct RoomError: Error {}

View File

@@ -542,6 +542,7 @@ extension SiteRoute.View.ProjectRoute.DuctSizingRoute {
return await ResultView {
let room = try await database.rooms.deleteRectangularSize(roomID, rectangularSizeID)
return try await database.calculateDuctSizes(projectID: projectID)
.rooms
.filter({ $0.roomID == room.id })
.first!
} onSuccess: { container in
@@ -559,11 +560,30 @@ extension SiteRoute.View.ProjectRoute.DuctSizingRoute {
)
)
return try await database.calculateDuctSizes(projectID: projectID)
.rooms
.filter({ $0.roomID == room.id })
.first!
} onSuccess: { container in
DuctSizingView.RoomRow(projectID: projectID, room: container)
}
case .trunk(let route):
switch route {
case .delete(let id):
return await ResultView {
try await database.trunkSizes.delete(id)
}
case .submit(let form):
return await view(on: request, projectID: projectID) {
_ = try await database.trunkSizes.create(
form.toCreate(logger: request.logger)
)
}
case .update(let id, let form):
// FIX:
fatalError()
}
}
}
@@ -581,9 +601,9 @@ extension SiteRoute.View.ProjectRoute.DuctSizingRoute {
try await database.projects.getCompletedSteps(projectID),
try await database.calculateDuctSizes(projectID: projectID)
)
} onSuccess: { (steps, rooms) in
} onSuccess: { (steps, ducts) in
ProjectView(projectID: projectID, activeTab: .ductSizing, completedSteps: steps) {
DuctSizingView(rooms: rooms)
DuctSizingView(rooms: ducts.rooms, trunks: ducts.trunks)
}
}
}

View File

@@ -3,14 +3,22 @@ import ElementaryHTMX
import ManualDCore
import Styleguide
// TODO: Add error text if prior steps are not completed.
// TODO: Add trunk size table.
struct DuctSizingView: HTML, Sendable {
@Environment(ProjectViewValue.$projectID) var projectID
// let projectID: Project.ID
let rooms: [DuctSizing.RoomContainer]
let trunks: [DuctSizing.TrunkContainer]
var supplyTrunks: [DuctSizing.TrunkContainer] {
trunks.filter { $0.trunk.type == .supply }
}
var returnTrunks: [DuctSizing.TrunkContainer] {
trunks.filter { $0.trunk.type == .return }
}
var body: some HTML {
div(.class("space-y-4")) {
@@ -22,6 +30,31 @@ struct DuctSizingView: HTML, Sendable {
} else {
RoomsTable(projectID: projectID, rooms: rooms)
}
Row {
h2(.class("text-2xl font-bold")) { "Trunk Sizes" }
PlusButton()
.attributes(
.class("me-6"),
.showModal(id: TrunkSizeForm.id())
)
}
.attributes(.class("mt-6"))
div(.class("divider -mt-2")) {}
if supplyTrunks.count > 0 {
h2(.class("text-lg font-bold text-info")) { "Supply Trunks" }
TrunkTable(trunks: supplyTrunks, rooms: rooms)
}
if returnTrunks.count > 0 {
h2(.class("text-lg font-bold text-error")) { "Return Trunks" }
TrunkTable(trunks: returnTrunks, rooms: rooms)
}
TrunkSizeForm(rooms: rooms, dismiss: true)
}
}
@@ -90,8 +123,8 @@ struct DuctSizingView: HTML, Sendable {
.attributes(.class("badge-secondary"))
}
td {
Number(room.flexSize)
.attributes(.class("badge badge-outline badge-primary text-xl font-bold"))
Badge(number: room.flexSize)
.attributes(.class("badge-primary"))
}
td {
if let width = room.rectangularWidth {
@@ -141,6 +174,122 @@ struct DuctSizingView: HTML, Sendable {
}
}
}
struct TrunkTable: HTML, Sendable {
let trunks: [DuctSizing.TrunkContainer]
let rooms: [DuctSizing.RoomContainer]
var body: some HTML {
div(.class("overflow-x-auto")) {
table(.class("table table-zebra text-lg")) {
thead {
tr(.class("text-lg")) {
th { "Associated Supplies" }
th { "Dsn CFM" }
th { "Round Size" }
th { "Velocity" }
th { "Final Size" }
th { "Flex Size" }
th { "Width" }
th { "Height" }
}
}
tbody {
for trunk in trunks {
tr {
td(.class("space-x-2")) {
// div(.class("flex flex-wrap space-x-2 max-w-1/3")) {
for id in registerIDS(trunk.trunk) {
Badge { id }
}
// }
}
td {
Number(trunk.ductSize.designCFM.value, digits: 0)
}
td {
Number(trunk.ductSize.roundSize, digits: 1)
}
td {
Number(trunk.ductSize.velocity)
}
td {
Badge(number: trunk.ductSize.finalSize)
.attributes(.class("badge-secondary"))
}
td {
Badge(number: trunk.ductSize.flexSize)
.attributes(.class("badge-primary"))
}
td {
if let width = trunk.ductSize.width {
Number(width)
}
}
td {
div(.class("flex justify-between items-center space-x-4")) {
div {
if let height = trunk.ductSize.height {
Number(height)
}
}
div {
div(.class("join")) {
TrashButton()
.attributes(.class("join-item btn-ghost"))
.attributes(
// .hx.delete(
// route: .project(
// .detail(
// projectID,
// .ductSizing(
// .deleteRectangularSize(
// room.roomID,
// room.rectangularSize?.id ?? .init())
// )
// )
// )
// ),
.hx.target("closest tr"),
.hx.swap(.outerHTML)
// when: room.rectangularSize != nil
)
EditButton()
.attributes(
.class("join-item btn-ghost"),
// .showModal(id: RectangularSizeForm.id(room))
)
}
}
}
// FIX: Add Trunk form.
}
}
}
}
}
}
}
func registerIDS(_ trunk: DuctSizing.TrunkSize) -> [String] {
trunk.rooms.reduce(into: []) { array, room in
array = room.registers.reduce(into: array) { array, register in
if let room =
rooms
.first(where: { $0.roomID == room.id && $0.roomRegister == register })
{
array.append(room.registerID)
}
}
}
.sorted()
}
}
}
extension DuctSizing.DesignCFM {

View File

@@ -0,0 +1,77 @@
import Elementary
import ElementaryHTMX
import ManualDCore
import Styleguide
struct TrunkSizeForm: HTML, Sendable {
static func id() -> String {
"trunkSizeForm"
}
@Environment(ProjectViewValue.$projectID) var projectID
let rooms: [DuctSizing.RoomContainer]
let dismiss: Bool
var route: String {
SiteRoute.View.router
.path(for: .project(.detail(projectID, .ductSizing(.index))))
.appendingPath(SiteRoute.View.ProjectRoute.DuctSizingRoute.TrunkRoute.rootPath)
}
var body: some HTML {
ModalForm(id: Self.id(), dismiss: dismiss) {
h1(.class("text-lg font-bold mb-4")) { "Trunk Size" }
form(
.class("space-y-4"),
.hx.post(route),
.hx.target("body"),
.hx.swap(.outerHTML)
) {
input(.class("hidden"), .name("projectID"), .value(projectID))
div(.class("grid grid-cols-1 md:grid-cols-2 gap-4")) {
label(.class("select w-full")) {
span(.class("label")) { "Type" }
select(.name("type")) {
for type in DuctSizing.TrunkSize.TrunkType.allCases {
option(.value(type.rawValue)) { type.rawValue.capitalized }
}
}
}
LabeledInput(
"Height",
.type(.text),
.name("height"),
.placeholder("8 (Optional)"),
)
}
// Add room select here.
div(.class("grid grid-cols-5 gap-6")) {
h2(.class("label font-bold col-span-5")) { "Associated Supply Runs" }
for room in rooms {
div(.class("flex justify-center items-center col-span-1")) {
div(.class("space-y-1")) {
p(.class("label block")) { room.registerID }
input(
.class("checkbox"),
.type(.checkbox),
.name("rooms"),
.value("\(room.roomID)_\(room.roomRegister)")
)
}
}
}
}
SubmitButton()
.attributes(.class("btn-block"))
}
}
}
}

View File

@@ -33,7 +33,7 @@ struct EquipmentInfoForm: HTML, Sendable {
equipmentInfo != nil
? .hx.patch(route)
: .hx.post(route),
.hx.target("#equipmentInfo"),
.hx.target("body"),
.hx.swap(.outerHTML)
) {
input(.class("hidden"), .name("projectID"), .value("\(projectID)"))