17 Commits

Author SHA1 Message Date
69e8acc5d8 WIP: Cleans up ResultView, fixes creating a new trunk size after changes to the database client, pdf button shows html now. 2026-01-27 10:12:51 -05:00
066b3003d0 WIP: Using project detail for duct size calculations, fix trunk sizes querying database for room data, it's now eagerly loaded. 2026-01-27 08:59:36 -05:00
1663c0a514 WIP: Working on a project detail database request to minimize database calls. 2026-01-26 16:43:16 -05:00
e08d896758 feat: Adds better mock support for models to aid in testing / viewing a mock project for the pdf client. 2026-01-26 13:39:27 -05:00
5fa11ae584 WIP: Style updates for pdf view. 2026-01-18 18:49:20 -05:00
04a7405ca4 WIP: Html view that prints to pdf ok. 2026-01-17 20:40:49 -05:00
0fe80d05c6 WIP: Begins work on pdf client. 2026-01-17 12:59:46 -05:00
3ec1ee2814 feat: Begins creating an auth client and integrates into view controller routes. 2026-01-16 17:04:05 -05:00
761ba29c1e feat: Cleans up / renames some manual-d client routes. 2026-01-16 12:10:33 -05:00
13c4bb33b5 feat: Reorganizes / creates duct sizes container, uses it in views and projectClient. 2026-01-16 11:40:21 -05:00
146baa7815 feat: Moves TrunkSize to be it's own namespace rather than being under DuctSizing, as it's got it's own database model, etc. 2026-01-16 10:48:07 -05:00
b5436c2073 feat: Moves rectangular size to room namespace instead of under duct sizing, since it's stored on the room database model. 2026-01-16 10:26:11 -05:00
59c1c9ec4a feat: Uses shared duct size container for both room and trunk duct sizing containers. 2026-01-16 10:16:31 -05:00
65fc8565b6 feat: More cleanup / renaming for project client. 2026-01-16 09:40:15 -05:00
d14477e97a feat: Cleans up / moves helpers for view controller to project client. 2026-01-16 09:31:35 -05:00
dbec7fb920 WIP: Attempt at breaking out some logic / middleware between database and view layer, to remove some code from the view controller. Not complete, maybe revert. 2026-01-15 23:02:36 -05:00
6b8cb73434 feat: Adds TODO's. 2026-01-15 19:51:26 -05:00
70 changed files with 3161 additions and 967 deletions

2
.gitignore vendored
View File

@@ -9,3 +9,5 @@ DerivedData/
.swift-version
node_modules/
tailwindcss
.envrc
*.pdf

View File

@@ -7,7 +7,10 @@ let package = Package(
products: [
.executable(name: "App", targets: ["App"]),
.library(name: "ApiController", targets: ["ApiController"]),
.library(name: "AuthClient", targets: ["AuthClient"]),
.library(name: "DatabaseClient", targets: ["DatabaseClient"]),
.library(name: "PdfClient", targets: ["PdfClient"]),
.library(name: "ProjectClient", targets: ["ProjectClient"]),
.library(name: "ManualDCore", targets: ["ManualDCore"]),
.library(name: "ManualDClient", targets: ["ManualDClient"]),
.library(name: "Styleguide", targets: ["Styleguide"]),
@@ -31,6 +34,7 @@ let package = Package(
name: "App",
dependencies: [
.target(name: "ApiController"),
.target(name: "AuthClient"),
.target(name: "DatabaseClient"),
.target(name: "ViewController"),
.product(name: "Dependencies", package: "swift-dependencies"),
@@ -53,6 +57,15 @@ let package = Package(
.product(name: "Vapor", package: "vapor"),
]
),
.target(
name: "AuthClient",
dependencies: [
.target(name: "DatabaseClient"),
.target(name: "ManualDCore"),
.product(name: "Dependencies", package: "swift-dependencies"),
.product(name: "DependenciesMacros", package: "swift-dependencies"),
]
),
.target(
name: "DatabaseClient",
dependencies: [
@@ -63,6 +76,23 @@ let package = Package(
.product(name: "Vapor", package: "vapor"),
]
),
.target(
name: "PdfClient",
dependencies: [
.target(name: "ManualDCore"),
.product(name: "Dependencies", package: "swift-dependencies"),
.product(name: "DependenciesMacros", package: "swift-dependencies"),
.product(name: "Elementary", package: "elementary"),
]
),
.target(
name: "ProjectClient",
dependencies: [
.target(name: "DatabaseClient"),
.target(name: "ManualDClient"),
.target(name: "PdfClient"),
]
),
.target(
name: "ManualDCore",
dependencies: [
@@ -104,7 +134,10 @@ let package = Package(
.target(
name: "ViewController",
dependencies: [
.target(name: "AuthClient"),
.target(name: "DatabaseClient"),
.target(name: "PdfClient"),
.target(name: "ProjectClient"),
.target(name: "ManualDClient"),
.target(name: "ManualDCore"),
.target(name: "Styleguide"),

103
Public/css/pdf.css Normal file
View File

@@ -0,0 +1,103 @@
@media print {
body {
-webkit-print-color-adjust: exact;
color-adjust: exact;
print-color-adjust: exact;
}
table td, table th {
-webkit-print-color-adjust: exact;
}
}
table {
border-collapse: collapse;
max-width: 100%;
margin: 10px auto;
border: none !important;
border-style: none;
}
th, td {
padding: 10px;
border: none;
border-style: none;
}
.table-bordered {
border: 1px solid #ccc;
}
.table-bordered th, td {
border: 1px solid #ccc;
}
.table-bordered tr:nth-child(even) {
background-color: #f2f2f2;
}
.w-full {
width: 100%;
}
.w-half {
width: 50%;
}
.table-footer {
background-color: #75af4c;
color: white;
font-weight: bold;
}
.bg-green {
background-color: #4CAF50;
color: white;
}
.heating {
color: red;
}
.coolingTotal {
color: blue;
}
.coolingSensible {
color: cyan;
}
.justify-end {
text-align: end;
}
.flex {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 10px;
}
.flex table {
width: 50%;
margin: 0;
flex: 1 1 calc(50% - 10px);
}
.container {
display: flex;
width: 100%;
gap: 10px;
}
.table-container {
flex: 1;
min-width: 0;
}
.table-container table {
width: 100%;
border-collapse: collapse;
}
.customerTable {
width: 50%;
}
.section {
padding: 10px;
}
.label {
font-weight: bold;
}
.error {
color: red;
font-weight: bold;
}
.effectiveLengthGroupTable, .effectiveLengthGroupHeader {
background-color: white;
color: black;
font-weight: bold;
}
.headline {
padding: 10px 0;
}

View File

@@ -33,6 +33,8 @@ extension SiteRoute.Api.ProjectRoute {
return nil
case .detail(let id, let route):
switch route {
case .index:
return try await database.projects.detail(id)
case .completedSteps:
// FIX:
fatalError()

View File

@@ -12,11 +12,7 @@ extension ViewController {
.init(
route: route,
isHtmxRequest: request.isHtmxRequest,
logger: request.logger,
authenticateUser: { request.session.authenticate($0) },
currentUser: {
try request.auth.require(User.self)
}
logger: request.logger
)
)
return AnyHTMLResponse(value: html)

View File

@@ -1,12 +1,14 @@
import ApiController
import AuthClient
import DatabaseClient
import Dependencies
import ManualDCore
import PdfClient
import Vapor
import ViewController
// Taken from discussions page on `swift-dependencies`.
// FIX: Use live view controller.
struct DependenciesMiddleware: AsyncMiddleware {
private let values: DependencyValues.Continuation
@@ -29,9 +31,11 @@ struct DependenciesMiddleware: AsyncMiddleware {
try await values.yield {
try await withDependencies {
$0.apiController = apiController
$0.authClient = .live(on: request)
$0.database = database
// $0.dateFormatter = .liveValue
$0.viewController = viewController
$0.pdfClient = .liveValue
} operation: {
try await next.respond(to: request)
}

View File

@@ -5,6 +5,7 @@ import Fluent
import FluentSQLiteDriver
import ManualDCore
import NIOSSL
import ProjectClient
import Vapor
import VaporElementary
@preconcurrency import VaporRouting
@@ -111,6 +112,8 @@ extension SiteRoute {
}
}
extension DuctSizes: Content {}
@Sendable
private func siteHandler(
request: Request,
@@ -118,6 +121,7 @@ private func siteHandler(
) async throws -> any AsyncResponseEncodable {
@Dependency(\.apiController) var apiController
@Dependency(\.viewController) var viewController
@Dependency(\.projectClient) var projectClient
switch route {
case .api(let route):

View File

@@ -0,0 +1,51 @@
import DatabaseClient
import Dependencies
import DependenciesMacros
import ManualDCore
import Vapor
extension DependencyValues {
public var authClient: AuthClient {
get { self[AuthClient.self] }
set { self[AuthClient.self] = newValue }
}
}
@DependencyClient
public struct AuthClient: Sendable {
public var createAndLogin: @Sendable (User.Create) async throws -> User
public var currentUser: @Sendable () throws -> User
public var login: @Sendable (User.Login) async throws -> User
public var logout: @Sendable () throws -> Void
}
extension AuthClient: TestDependencyKey {
public static let testValue = Self()
public static func live(on request: Request) -> Self {
@Dependency(\.database) var database
return .init(
createAndLogin: { createForm in
let user = try await database.users.create(createForm)
_ = try await database.users.login(
.init(email: createForm.email, password: createForm.password)
)
request.auth.login(user)
request.logger.debug("LOGGED IN: \(user.id)")
return user
},
currentUser: {
try request.auth.require(User.self)
},
login: { loginForm in
let token = try await database.users.login(loginForm)
let user = try await database.users.get(token.userID)!
request.session.authenticate(user)
request.logger.debug("LOGGED IN: \(user.id)")
return user
},
logout: { request.auth.logout(User.self) }
)
}
}

View File

@@ -74,7 +74,7 @@ extension DatabaseClient.Migrations: DependencyKey {
EquipmentInfo.Migrate(),
Room.Migrate(),
EffectiveLength.Migrate(),
DuctSizing.TrunkSize.Migrate(),
TrunkSize.Migrate(),
]
}
)

View File

@@ -9,6 +9,7 @@ extension DatabaseClient {
public struct Projects: Sendable {
public var create: @Sendable (User.ID, Project.Create) async throws -> Project
public var delete: @Sendable (Project.ID) async throws -> Void
public var detail: @Sendable (Project.ID) async throws -> Project.Detail?
public var get: @Sendable (Project.ID) async throws -> Project?
public var getCompletedSteps: @Sendable (Project.ID) async throws -> Project.CompletedSteps
public var getSensibleHeatRatio: @Sendable (Project.ID) async throws -> Double?
@@ -33,6 +34,44 @@ extension DatabaseClient.Projects: TestDependencyKey {
}
try await model.delete(on: database)
},
detail: { id in
guard
let model = try await ProjectModel.query(on: database)
.with(\.$componentLosses)
.with(\.$equipment)
.with(\.$equivalentLengths)
.with(\.$rooms)
.with(
\.$trunks,
{ trunk in
trunk.with(
\.$rooms,
{
$0.with(\.$room)
}
)
}
)
.filter(\.$id == id)
.first()
else {
throw NotFoundError()
}
// TODO: Different error ??
guard let equipmentInfo = model.equipment else { return nil }
let trunks = try model.trunks.toDTO()
return try .init(
project: model.toDTO(),
componentLosses: model.componentLosses.map { try $0.toDTO() },
equipmentInfo: equipmentInfo.toDTO(),
equivalentLengths: model.equivalentLengths.map { try $0.toDTO() },
rooms: model.rooms.map { try $0.toDTO() },
trunks: trunks
)
},
get: { id in
try await ProjectModel.find(id, on: database).map { try $0.toDTO() }
},
@@ -73,10 +112,16 @@ extension DatabaseClient.Projects: TestDependencyKey {
)
},
getSensibleHeatRatio: { id in
guard let model = try await ProjectModel.find(id, on: database) else {
guard
let shr = try await ProjectModel.query(on: database)
.field(\.$id)
.field(\.$sensibleHeatRatio)
.filter(\.$id == id)
.first()
else {
throw NotFoundError()
}
return model.sensibleHeatRatio
return shr.sensibleHeatRatio
},
fetch: { userID, request in
try await ProjectModel.query(on: database)
@@ -242,6 +287,18 @@ final class ProjectModel: Model, @unchecked Sendable {
@Children(for: \.$project)
var componentLosses: [ComponentLossModel]
@OptionalChild(for: \.$project)
var equipment: EquipmentModel?
@Children(for: \.$project)
var equivalentLengths: [EffectiveLengthModel]
@Children(for: \.$project)
var rooms: [RoomModel]
@Children(for: \.$project)
var trunks: [TrunkModel]
@Parent(key: "userID")
var user: UserModel

View File

@@ -10,12 +10,11 @@ extension DatabaseClient {
public var create: @Sendable (Room.Create) async throws -> Room
public var delete: @Sendable (Room.ID) async throws -> Void
public var deleteRectangularSize:
@Sendable (Room.ID, DuctSizing.RectangularDuct.ID) async throws -> Room
@Sendable (Room.ID, Room.RectangularSize.ID) async throws -> Room
public var get: @Sendable (Room.ID) async throws -> Room?
public var fetch: @Sendable (Project.ID) async throws -> [Room]
public var update: @Sendable (Room.ID, Room.Update) async throws -> Room
public var updateRectangularSize:
@Sendable (Room.ID, DuctSizing.RectangularDuct) async throws -> Room
public var updateRectangularSize: @Sendable (Room.ID, Room.RectangularSize) async throws -> Room
}
}
@@ -204,7 +203,7 @@ final class RoomModel: Model, @unchecked Sendable {
var registerCount: Int
@Field(key: "rectangularSizes")
var rectangularSizes: [DuctSizing.RectangularDuct]?
var rectangularSizes: [Room.RectangularSize]?
@Timestamp(key: "createdAt", on: .create, format: .iso8601)
var createdAt: Date?
@@ -224,7 +223,7 @@ final class RoomModel: Model, @unchecked Sendable {
coolingTotal: Double,
coolingSensible: Double? = nil,
registerCount: Int,
rectangularSizes: [DuctSizing.RectangularDuct]? = nil,
rectangularSizes: [Room.RectangularSize]? = nil,
createdAt: Date? = nil,
updatedAt: Date? = nil,
projectID: Project.ID

View File

@@ -7,13 +7,13 @@ import ManualDCore
extension DatabaseClient {
@DependencyClient
public struct TrunkSizes: Sendable {
public var create: @Sendable (DuctSizing.TrunkSize.Create) async throws -> DuctSizing.TrunkSize
public var delete: @Sendable (DuctSizing.TrunkSize.ID) async throws -> Void
public var fetch: @Sendable (Project.ID) async throws -> [DuctSizing.TrunkSize]
public var get: @Sendable (DuctSizing.TrunkSize.ID) async throws -> DuctSizing.TrunkSize?
public var create: @Sendable (TrunkSize.Create) async throws -> TrunkSize
public var delete: @Sendable (TrunkSize.ID) async throws -> Void
public var fetch: @Sendable (Project.ID) async throws -> [TrunkSize]
public var get: @Sendable (TrunkSize.ID) async throws -> TrunkSize?
public var update:
@Sendable (DuctSizing.TrunkSize.ID, DuctSizing.TrunkSize.Update) async throws ->
DuctSizing.TrunkSize
@Sendable (TrunkSize.ID, TrunkSize.Update) async throws ->
TrunkSize
}
}
@@ -26,7 +26,7 @@ extension DatabaseClient.TrunkSizes: TestDependencyKey {
try request.validate()
let trunk = request.toModel()
var roomProxies = [DuctSizing.TrunkSize.RoomProxy]()
var roomProxies = [TrunkSize.RoomProxy]()
try await trunk.save(on: database)
@@ -41,7 +41,9 @@ extension DatabaseClient.TrunkSizes: TestDependencyKey {
type: request.type
)
try await model.save(on: database)
try await roomProxies.append(model.toDTO(on: database))
roomProxies.append(
.init(room: try room.toDTO(), registers: registers)
)
}
return try .init(
@@ -60,23 +62,30 @@ extension DatabaseClient.TrunkSizes: TestDependencyKey {
fetch: { projectID in
try await TrunkModel.query(on: database)
.with(\.$project)
.with(\.$rooms)
.with(\.$rooms, { $0.with(\.$room) })
.filter(\.$project.$id == projectID)
.all()
.toDTO(on: database)
.toDTO()
},
get: { id in
guard let model = try await TrunkModel.find(id, on: database) else {
guard
let model =
try await TrunkModel
.query(on: database)
.with(\.$rooms, { $0.with(\.$room) })
.filter(\.$id == id)
.first()
else {
return nil
}
return try await model.toDTO(on: database)
return try model.toDTO()
},
update: { id, updates in
guard
let model =
try await TrunkModel
.query(on: database)
.with(\.$rooms)
.with(\.$rooms, { $0.with(\.$room) })
.filter(\.$id == id)
.first()
else {
@@ -84,13 +93,13 @@ extension DatabaseClient.TrunkSizes: TestDependencyKey {
}
try updates.validate()
try await model.applyUpdates(updates, on: database)
return try await model.toDTO(on: database)
return try model.toDTO()
}
)
}
}
extension DuctSizing.TrunkSize.Create {
extension TrunkSize.Create {
func validate() throws(ValidationError) {
guard rooms.count > 0 else {
@@ -113,7 +122,7 @@ extension DuctSizing.TrunkSize.Create {
}
}
extension DuctSizing.TrunkSize.Update {
extension TrunkSize.Update {
func validate() throws(ValidationError) {
if let rooms {
guard rooms.count > 0 else {
@@ -128,7 +137,7 @@ extension DuctSizing.TrunkSize.Update {
}
}
extension DuctSizing.TrunkSize {
extension TrunkSize {
struct Migrate: AsyncMigration {
let name = "CreateTrunkSize"
@@ -192,7 +201,7 @@ final class TrunkRoomModel: Model, @unchecked Sendable {
trunkID: TrunkModel.IDValue,
roomID: RoomModel.IDValue,
registers: [Int],
type: DuctSizing.TrunkSize.TrunkType
type: TrunkSize.TrunkType
) {
self.id = id
$trunk.id = trunkID
@@ -201,10 +210,10 @@ final class TrunkRoomModel: Model, @unchecked Sendable {
self.type = type.rawValue
}
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()
}
func toDTO() throws -> TrunkSize.RoomProxy {
// guard let room = try await RoomModel.find($room.id, on: database) else {
// throw NotFoundError()
// }
return .init(
room: try room.toDTO(),
registers: registers
@@ -240,7 +249,7 @@ final class TrunkModel: Model, @unchecked Sendable {
init(
id: UUID? = nil,
projectID: Project.ID,
type: DuctSizing.TrunkSize.TrunkType,
type: TrunkSize.TrunkType,
height: Int? = nil,
name: String? = nil
) {
@@ -251,18 +260,22 @@ final class TrunkModel: Model, @unchecked Sendable {
self.name = name
}
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)
}
func toDTO() throws -> TrunkSize {
// let rooms = try await withThrowingTaskGroup(of: TrunkSize.RoomProxy.self) { group in
// for room in self.rooms {
// group.addTask {
// try await room.toDTO(on: database)
// }
// }
//
// return try await group.reduce(into: [TrunkSize.RoomProxy]()) {
// $0.append($1)
// }
//
// }
let rooms = try rooms.reduce(into: [TrunkSize.RoomProxy]()) {
$0.append(try $1.toDTO())
}
return try .init(
@@ -277,7 +290,7 @@ final class TrunkModel: Model, @unchecked Sendable {
}
func applyUpdates(
_ updates: DuctSizing.TrunkSize.Update,
_ updates: TrunkSize.Update,
on database: any Database
) async throws {
if let type = updates.type, type.rawValue != self.type {
@@ -340,17 +353,17 @@ final class TrunkModel: Model, @unchecked Sendable {
extension Array where Element == TrunkModel {
func toDTO(on database: any Database) async throws -> [DuctSizing.TrunkSize] {
try await withThrowingTaskGroup(of: DuctSizing.TrunkSize.self) { group in
for model in self {
group.addTask {
try await model.toDTO(on: database)
}
}
func toDTO() throws -> [TrunkSize] {
// try await withThrowingTaskGroup(of: TrunkSize.self) { group in
// for model in self {
// group.addTask {
// try await model.toDTO(on: database)
// }
// }
return try await group.reduce(into: [DuctSizing.TrunkSize]()) {
$0.append($1)
}
return try reduce(into: [TrunkSize]()) {
$0.append(try $1.toDTO())
}
}
// }
}

View File

@@ -14,7 +14,7 @@ extension Room {
}
}
extension DuctSizing.TrunkSize.RoomProxy {
extension TrunkSize.RoomProxy {
// We need to make sure if registers got removed after a trunk
// was already made / saved that we do not include registers that
@@ -35,7 +35,7 @@ extension DuctSizing.TrunkSize.RoomProxy {
}
}
extension DuctSizing.TrunkSize {
extension TrunkSize {
var totalHeatingLoad: Double {
rooms.reduce(into: 0) { $0 += $1.totalHeatingLoad }

View File

@@ -0,0 +1,129 @@
import Dependencies
import DependenciesMacros
import Logging
import ManualDCore
extension DependencyValues {
public var manualD: ManualDClient {
get { self[ManualDClient.self] }
set { self[ManualDClient.self] = newValue }
}
}
/// Performs manual-d duct sizing calculations.
///
///
@DependencyClient
public struct ManualDClient: Sendable {
public var ductSize: @Sendable (DuctSizeRequest) async throws -> DuctSizeResponse
public var frictionRate: @Sendable (FrictionRateRequest) async throws -> FrictionRate
public var totalEquivalentLength: @Sendable (TotalEquivalentLengthRequest) async throws -> Int
public var rectangularSize:
@Sendable (RectangularSizeRequest) async throws -> RectangularSizeResponse
}
extension ManualDClient: TestDependencyKey {
public static let testValue = Self()
}
extension ManualDClient {
public struct DuctSizeRequest: Codable, Equatable, Sendable {
public let designCFM: Int
public let frictionRate: Double
public init(
designCFM: Int,
frictionRate: Double
) {
self.designCFM = designCFM
self.frictionRate = frictionRate
}
}
public struct DuctSizeResponse: Codable, Equatable, Sendable {
public let calculatedSize: Double
public let finalSize: Int
public let flexSize: Int
public let velocity: Int
public init(
calculatedSize: Double,
finalSize: Int,
flexSize: Int,
velocity: Int
) {
self.calculatedSize = calculatedSize
self.finalSize = finalSize
self.flexSize = flexSize
self.velocity = velocity
}
}
public struct FrictionRateRequest: Codable, Equatable, Sendable {
public let externalStaticPressure: Double
public let componentPressureLosses: [ComponentPressureLoss]
public let totalEffectiveLength: Int
public init(
externalStaticPressure: Double,
componentPressureLosses: [ComponentPressureLoss],
totalEffectiveLength: Int
) {
self.externalStaticPressure = externalStaticPressure
self.componentPressureLosses = componentPressureLosses
self.totalEffectiveLength = totalEffectiveLength
}
}
public struct FrictionRateResponse: Codable, Equatable, Sendable {
public let availableStaticPressure: Double
public let frictionRate: Double
public init(availableStaticPressure: Double, frictionRate: Double) {
self.availableStaticPressure = availableStaticPressure
self.frictionRate = frictionRate
}
}
public struct TotalEquivalentLengthRequest: Codable, Equatable, Sendable {
public let trunkLengths: [Int]
public let runoutLengths: [Int]
public let effectiveLengthGroups: [EffectiveLengthGroup]
public init(
trunkLengths: [Int],
runoutLengths: [Int],
effectiveLengthGroups: [EffectiveLengthGroup]
) {
self.trunkLengths = trunkLengths
self.runoutLengths = runoutLengths
self.effectiveLengthGroups = effectiveLengthGroups
}
}
public struct RectangularSizeRequest: Codable, Equatable, Sendable {
public let roundSize: Int
public let height: Int
public init(round roundSize: Int, height: Int) {
self.roundSize = roundSize
self.height = height
}
}
public struct RectangularSizeResponse: Codable, Equatable, Sendable {
public let height: Int
public let width: Int
public init(height: Int, width: Int) {
self.height = height
self.width = width
}
}
}

View File

@@ -13,7 +13,7 @@ extension ManualDClient: DependencyKey {
let finalSize = try roundSize(ductulatorSize)
let flexSize = try flexSize(request)
return .init(
ductulatorSize: ductulatorSize,
calculatedSize: ductulatorSize,
finalSize: finalSize,
flexSize: flexSize,
velocity: velocity(cfm: request.designCFM, roundSize: finalSize)
@@ -28,15 +28,15 @@ extension ManualDClient: DependencyKey {
let totalComponentLosses = request.componentPressureLosses.total
let availableStaticPressure = request.externalStaticPressure - totalComponentLosses
let frictionRate = availableStaticPressure * 100.0 / Double(request.totalEffectiveLength)
return .init(availableStaticPressure: availableStaticPressure, frictionRate: frictionRate)
return .init(availableStaticPressure: availableStaticPressure, value: frictionRate)
},
totalEffectiveLength: { request in
totalEquivalentLength: { request in
let trunkLengths = request.trunkLengths.reduce(0) { $0 + $1 }
let runoutLengths = request.runoutLengths.reduce(0) { $0 + $1 }
let groupLengths = request.effectiveLengthGroups.totalEffectiveLength
return trunkLengths + runoutLengths + groupLengths
},
equivalentRectangularDuct: { request in
rectangularSize: { request in
let width = (Double.pi * (pow(Double(request.roundSize) / 2.0, 2.0))) / Double(request.height)
return .init(height: request.height, width: Int(width.rounded(.toNearestOrEven)))
}

View File

@@ -1,274 +0,0 @@
import Dependencies
import DependenciesMacros
import Logging
import ManualDCore
@DependencyClient
public struct ManualDClient: Sendable {
public var ductSize: @Sendable (DuctSizeRequest) async throws -> DuctSizeResponse
public var frictionRate: @Sendable (FrictionRateRequest) async throws -> FrictionRateResponse
public var totalEffectiveLength: @Sendable (TotalEffectiveLengthRequest) async throws -> Int
public var equivalentRectangularDuct:
@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,
maxReturnLength: EffectiveLength,
designFrictionRate: Double,
projectSHR: Double,
logger: Logger? = nil
) async throws -> [DuctSizing.RoomContainer] {
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 {
var rectangularWidth: Int? = nil
let rectangularSize = room.rectangularSizes?
.first(where: { $0.register == nil || $0.register == n })
if let rectangularSize {
let response = try await self.equivalentRectangularDuct(
.init(round: sizes.finalSize, height: rectangularSize.height)
)
rectangularWidth = response.width
}
retval.append(
.init(
roomID: room.id,
roomName: "\(room.name)-\(n)",
roomRegister: n,
heatingLoad: heatingLoad,
coolingLoad: coolingLoad,
heatingCFM: heatingCFM,
coolingCFM: coolingCFM,
designCFM: designCFM,
roundSize: sizes.ductulatorSize,
finalSize: sizes.finalSize,
velocity: sizes.velocity,
flexSize: sizes.flexSize,
rectangularSize: rectangularSize,
rectangularWidth: rectangularWidth
)
)
}
}
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,
height: trunk.height,
width: width
)
)
)
}
return retval
}
}
extension ManualDClient: TestDependencyKey {
public static let testValue = Self()
}
extension DependencyValues {
public var manualD: ManualDClient {
get { self[ManualDClient.self] }
set { self[ManualDClient.self] = newValue }
}
}
// MARK: Duct Size
extension ManualDClient {
public struct DuctSizeRequest: Codable, Equatable, Sendable {
public let designCFM: Int
public let frictionRate: Double
public init(
designCFM: Int,
frictionRate: Double
) {
self.designCFM = designCFM
self.frictionRate = frictionRate
}
}
public struct DuctSizeResponse: Codable, Equatable, Sendable {
public let ductulatorSize: Double
public let finalSize: Int
public let flexSize: Int
public let velocity: Int
public init(
ductulatorSize: Double,
finalSize: Int,
flexSize: Int,
velocity: Int
) {
self.ductulatorSize = ductulatorSize
self.finalSize = finalSize
self.flexSize = flexSize
self.velocity = velocity
}
}
}
// MARK: - Friction Rate
extension ManualDClient {
public struct FrictionRateRequest: Codable, Equatable, Sendable {
public let externalStaticPressure: Double
public let componentPressureLosses: [ComponentPressureLoss]
public let totalEffectiveLength: Int
public init(
externalStaticPressure: Double,
componentPressureLosses: [ComponentPressureLoss],
totalEffectiveLength: Int
) {
self.externalStaticPressure = externalStaticPressure
self.componentPressureLosses = componentPressureLosses
self.totalEffectiveLength = totalEffectiveLength
}
}
public struct FrictionRateResponse: Codable, Equatable, Sendable {
public let availableStaticPressure: Double
public let frictionRate: Double
public init(availableStaticPressure: Double, frictionRate: Double) {
self.availableStaticPressure = availableStaticPressure
self.frictionRate = frictionRate
}
}
}
// MARK: Total Effective Length
extension ManualDClient {
public struct TotalEffectiveLengthRequest: Codable, Equatable, Sendable {
public let trunkLengths: [Int]
public let runoutLengths: [Int]
public let effectiveLengthGroups: [EffectiveLengthGroup]
public init(
trunkLengths: [Int],
runoutLengths: [Int],
effectiveLengthGroups: [EffectiveLengthGroup]
) {
self.trunkLengths = trunkLengths
self.runoutLengths = runoutLengths
self.effectiveLengthGroups = effectiveLengthGroups
}
}
}
// MARK: Equivalent Rectangular Duct
extension ManualDClient {
public struct EquivalentRectangularDuctRequest: Codable, Equatable, Sendable {
public let roundSize: Int
public let height: Int
public init(round roundSize: Int, height: Int) {
self.roundSize = roundSize
self.height = height
}
}
public struct EquivalentRectangularDuctResponse: Codable, Equatable, Sendable {
public let height: Int
public let width: Int
public init(height: Int, width: Int) {
self.height = height
self.width = width
}
}
}

View File

@@ -1,3 +1,4 @@
import Dependencies
import Foundation
public struct ComponentPressureLoss: Codable, Equatable, Identifiable, Sendable {
@@ -89,7 +90,61 @@ public typealias ComponentPressureLosses = [String: Double]
}
}
extension Array where Element == ComponentPressureLoss {
public static func mock(projectID: Project.ID) -> Self {
ComponentPressureLoss.mock(projectID: projectID)
}
}
extension ComponentPressureLoss {
public static func mock(projectID: Project.ID) -> [Self] {
@Dependency(\.uuid) var uuid
@Dependency(\.date.now) var now
return [
.init(
id: uuid(),
projectID: projectID,
name: "evaporator-coil",
value: 0.2,
createdAt: now,
updatedAt: now
),
.init(
id: uuid(),
projectID: projectID,
name: "filter",
value: 0.1,
createdAt: now,
updatedAt: now
),
.init(
id: uuid(),
projectID: projectID,
name: "supply-outlet",
value: 0.03,
createdAt: now,
updatedAt: now
),
.init(
id: uuid(),
projectID: projectID,
name: "return-grille",
value: 0.03,
createdAt: now,
updatedAt: now
),
.init(
id: uuid(),
projectID: projectID,
name: "balancing-damper",
value: 0.03,
createdAt: now,
updatedAt: now
),
]
}
public static var mock: [Self] {
[
.init(

View File

@@ -0,0 +1,256 @@
import Dependencies
import Foundation
public struct DuctSizes: Codable, Equatable, Sendable {
public let rooms: [RoomContainer]
public let trunks: [TrunkContainer]
public init(
rooms: [DuctSizes.RoomContainer],
trunks: [DuctSizes.TrunkContainer]
) {
self.rooms = rooms
self.trunks = trunks
}
}
extension DuctSizes {
public struct SizeContainer: Codable, Equatable, Sendable {
public let rectangularID: Room.RectangularSize.ID?
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(
rectangularID: Room.RectangularSize.ID? = nil,
designCFM: DuctSizes.DesignCFM,
roundSize: Double,
finalSize: Int,
velocity: Int,
flexSize: Int,
height: Int? = nil,
width: Int? = nil
) {
self.rectangularID = rectangularID
self.designCFM = designCFM
self.roundSize = roundSize
self.finalSize = finalSize
self.velocity = velocity
self.flexSize = flexSize
self.height = height
self.width = width
}
}
@dynamicMemberLookup
public struct RoomContainer: Codable, Equatable, Sendable {
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
public let coolingCFM: Double
public let ductSize: SizeContainer
public init(
roomID: Room.ID,
roomName: String,
roomRegister: Int,
heatingLoad: Double,
coolingLoad: Double,
heatingCFM: Double,
coolingCFM: Double,
ductSize: SizeContainer
) {
self.roomID = roomID
self.roomName = roomName
self.roomRegister = roomRegister
self.heatingLoad = heatingLoad
self.coolingLoad = coolingLoad
self.heatingCFM = heatingCFM
self.coolingCFM = coolingCFM
self.ductSize = ductSize
}
public subscript<T>(dynamicMember keyPath: KeyPath<DuctSizes.SizeContainer, T>) -> T {
ductSize[keyPath: keyPath]
}
}
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
}
}
}
// Represents the database model that the duct sizes have been calculated
// for.
@dynamicMemberLookup
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 subscript<T>(dynamicMember keyPath: KeyPath<TrunkSize, T>) -> T {
trunk[keyPath: keyPath]
}
public subscript<T>(dynamicMember keyPath: KeyPath<DuctSizes.SizeContainer, T>) -> T {
ductSize[keyPath: keyPath]
}
public func registerIDS(rooms: [RoomContainer]) -> [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.roomName)
}
}
}
.sorted()
}
}
}
#if DEBUG
extension DuctSizes {
public static func mock(
equipmentInfo: EquipmentInfo,
rooms: [Room],
trunks: [TrunkSize]
) -> Self {
let totalHeatingLoad = rooms.totalHeatingLoad
let totalCoolingLoad = rooms.totalCoolingLoad
let roomContainers = rooms.reduce(into: [RoomContainer]()) { array, room in
array += RoomContainer.mock(
room: room,
totalHeatingLoad: totalHeatingLoad,
totalCoolingLoad: totalCoolingLoad,
totalHeatingCFM: Double(equipmentInfo.heatingCFM),
totalCoolingCFM: Double(equipmentInfo.coolingCFM)
)
}
return .init(
rooms: roomContainers,
trunks: TrunkContainer.mock(
trunks: trunks,
totalHeatingLoad: totalHeatingLoad,
totalCoolingLoad: totalCoolingLoad,
totalHeatingCFM: Double(equipmentInfo.heatingCFM),
totalCoolingCFM: Double(equipmentInfo.coolingCFM)
)
)
}
}
extension DuctSizes.RoomContainer {
public static func mock(
room: Room,
totalHeatingLoad: Double,
totalCoolingLoad: Double,
totalHeatingCFM: Double,
totalCoolingCFM: 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 coolingFraction = coolingLoad / totalCoolingLoad
let coolingCFM = totalCoolingCFM * coolingFraction
for n in 1...room.registerCount {
retval.append(
.init(
roomID: room.id,
roomName: room.name,
roomRegister: n,
heatingLoad: heatingLoad,
coolingLoad: coolingLoad,
heatingCFM: heatingCFM,
coolingCFM: coolingCFM,
ductSize: .init(
rectangularID: nil,
designCFM: .init(heating: heatingCFM, cooling: coolingCFM),
roundSize: 7,
finalSize: 8,
velocity: 489,
flexSize: 8,
height: nil,
width: nil
)
)
)
}
return retval
}
}
extension DuctSizes.TrunkContainer {
public static func mock(
trunks: [TrunkSize],
totalHeatingLoad: Double,
totalCoolingLoad: Double,
totalHeatingCFM: Double,
totalCoolingCFM: Double
) -> [Self] {
trunks.reduce(into: []) { array, trunk in
array.append(
.init(
trunk: trunk,
ductSize: .init(
designCFM: .init(heating: totalHeatingCFM, cooling: totalCoolingCFM),
roundSize: 18,
finalSize: 20,
velocity: 987,
flexSize: 20
)
)
)
}
}
}
#endif

View File

@@ -1,248 +0,0 @@
import Dependencies
import Foundation
public enum DuctSizing {
public struct RectangularDuct: Codable, Equatable, Identifiable, Sendable {
public let id: UUID
public let register: Int?
public let height: Int
public init(
id: UUID = .init(),
register: Int? = nil,
height: Int,
) {
self.id = id
self.register = register
self.height = height
}
}
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
}
}
// TODO: Uses SizeContainer
public struct RoomContainer: Codable, Equatable, Sendable {
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
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 let rectangularSize: RectangularDuct?
public let rectangularWidth: Int?
public init(
roomID: Room.ID,
roomName: String,
roomRegister: Int,
heatingLoad: Double,
coolingLoad: Double,
heatingCFM: Double,
coolingCFM: Double,
designCFM: DesignCFM,
roundSize: Double,
finalSize: Int,
velocity: Int,
flexSize: Int,
rectangularSize: RectangularDuct? = nil,
rectangularWidth: Int? = nil
) {
self.roomID = roomID
self.roomName = roomName
self.roomRegister = roomRegister
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
self.rectangularSize = rectangularSize
self.rectangularWidth = rectangularWidth
}
}
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
}
}
}
}
extension DuctSizing {
// Represents the database model that the duct sizes have been calculated
// for.
@dynamicMemberLookup
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 subscript<T>(dynamicMember keyPath: KeyPath<DuctSizing.TrunkSize, T>) -> T {
trunk[keyPath: keyPath]
}
public subscript<T>(dynamicMember keyPath: KeyPath<DuctSizing.SizeContainer, T>) -> T {
ductSize[keyPath: keyPath]
}
}
// TODO: Add an optional label that the user can set.
// Represents the database model.
public struct TrunkSize: Codable, Equatable, Identifiable, Sendable {
public let id: UUID
public let projectID: Project.ID
public let type: TrunkType
public let rooms: [RoomProxy]
public let height: Int?
public let name: String?
public init(
id: UUID,
projectID: Project.ID,
type: DuctSizing.TrunkSize.TrunkType,
rooms: [DuctSizing.TrunkSize.RoomProxy],
height: Int? = nil,
name: String? = nil
) {
self.id = id
self.projectID = projectID
self.type = type
self.rooms = rooms
self.height = height
self.name = name
}
}
}
extension DuctSizing.TrunkSize {
public struct Create: Codable, Equatable, Sendable {
public let projectID: Project.ID
public let type: TrunkType
public let rooms: [Room.ID: [Int]]
public let height: Int?
public let name: String?
public init(
projectID: Project.ID,
type: DuctSizing.TrunkSize.TrunkType,
rooms: [Room.ID: [Int]],
height: Int? = nil,
name: String? = nil
) {
self.projectID = projectID
self.type = type
self.rooms = rooms
self.height = height
self.name = name
}
}
public struct Update: Codable, Equatable, Sendable {
public let type: TrunkType?
public let rooms: [Room.ID: [Int]]?
public let height: Int?
public let name: String?
public init(
type: DuctSizing.TrunkSize.TrunkType? = nil,
rooms: [Room.ID: [Int]]? = nil,
height: Int? = nil,
name: String? = nil
) {
self.type = type
self.rooms = rooms
self.height = height
self.name = name
}
}
// TODO: Make registers non-optional
public struct RoomProxy: Codable, Equatable, Identifiable, Sendable {
public var id: Room.ID { room.id }
public let room: Room
public let registers: [Int]
public init(room: Room, registers: [Int]) {
self.room = room
self.registers = registers
}
}
public enum TrunkType: String, CaseIterable, Codable, Equatable, Sendable {
case `return`
case supply
public static let allCases = [Self.supply, .return]
}
}

View File

@@ -142,6 +142,44 @@ extension Array where Element == EffectiveLength.Group {
#if DEBUG
extension EffectiveLength {
public static func mock(projectID: Project.ID) -> [Self] {
@Dependency(\.uuid) var uuid
@Dependency(\.date.now) var now
return [
.init(
id: uuid(),
projectID: projectID,
name: "Supply - 1",
type: .supply,
straightLengths: [10, 25],
groups: [
.init(group: 1, letter: "a", value: 20),
.init(group: 2, letter: "b", value: 30, quantity: 1),
.init(group: 3, letter: "a", value: 10, quantity: 1),
.init(group: 12, letter: "a", value: 10, quantity: 1),
],
createdAt: now,
updatedAt: now
),
.init(
id: uuid(),
projectID: projectID,
name: "Return - 1",
type: .return,
straightLengths: [10, 20, 5],
groups: [
.init(group: 5, letter: "a", value: 10),
.init(group: 6, letter: "a", value: 15, quantity: 1),
.init(group: 7, letter: "a", value: 20, quantity: 1),
],
createdAt: now,
updatedAt: now
),
]
}
public static let mocks: [Self] = [
.init(
id: UUID(0),

View File

@@ -70,6 +70,21 @@ extension EquipmentInfo {
#if DEBUG
extension EquipmentInfo {
public static func mock(projectID: Project.ID) -> Self {
@Dependency(\.uuid) var uuid
@Dependency(\.date.now) var now
return .init(
id: uuid(),
projectID: projectID,
heatingCFM: 900,
coolingCFM: 1000,
createdAt: now,
updatedAt: now
)
}
public static let mock = Self(
id: UUID(0),
projectID: UUID(0),

View File

@@ -0,0 +1,57 @@
/// Holds onto values returned when calculating the design
/// friction rate for a project.
public struct FrictionRate: Codable, Equatable, Sendable {
public let availableStaticPressure: Double
public let value: Double
public var hasErrors: Bool { error != nil }
public init(
availableStaticPressure: Double,
value: Double
) {
self.availableStaticPressure = availableStaticPressure
self.value = value
}
public var error: FrictionRateError? {
if value >= 0.18 {
return .init(
"Friction rate should be lower than 0.18",
resolutions: [
"Decrease the blower speed",
"Decrease the blower size",
"Increase the Total Equivalent Length",
]
)
} else if value <= 0.02 {
return .init(
"Friction rate should be higher than 0.02",
resolutions: [
"Increase the blower speed",
"Increase the blower size",
"Decrease the Total Equivalent Length",
]
)
}
return nil
}
}
public struct FrictionRateError: Error, Equatable, Sendable {
public let reason: String
public let resolutions: [String]
public init(
_ reason: String,
resolutions: [String]
) {
self.reason = reason
self.resolutions = resolutions
}
}
#if DEBUG
extension FrictionRate {
public static let mock = Self(availableStaticPressure: 0.21, value: 0.11)
}
#endif

View File

@@ -0,0 +1,24 @@
import Foundation
extension Double {
public func string(digits: Int = 2) -> String {
numberString(self, digits: digits)
}
}
extension Int {
public func string() -> String {
numberString(Double(self), digits: 0)
}
}
private func numberString(_ value: Double, digits: Int = 2) -> String {
let formatter = NumberFormatter()
formatter.maximumFractionDigits = digits
formatter.groupingSize = 3
formatter.groupingSeparator = ","
formatter.numberStyle = .decimal
return formatter.string(for: value)!
}

View File

@@ -0,0 +1,12 @@
import Fluent
extension PageRequest {
public static var first: Self {
.init(page: 1, per: 25)
}
public static func next<T>(_ currentPage: Page<T>) -> Self {
.init(page: currentPage.metadata.page + 1, per: currentPage.metadata.per)
}
}

View File

@@ -84,6 +84,32 @@ extension Project {
}
}
public struct Detail: Codable, Equatable, Sendable {
public let project: Project
public let componentLosses: [ComponentPressureLoss]
public let equipmentInfo: EquipmentInfo
public let equivalentLengths: [EffectiveLength]
public let rooms: [Room]
public let trunks: [TrunkSize]
public init(
project: Project,
componentLosses: [ComponentPressureLoss],
equipmentInfo: EquipmentInfo,
equivalentLengths: [EffectiveLength],
rooms: [Room],
trunks: [TrunkSize]
) {
self.project = project
self.componentLosses = componentLosses
self.equipmentInfo = equipmentInfo
self.equivalentLengths = equivalentLengths
self.rooms = rooms
self.trunks = trunks
}
}
public struct Update: Codable, Equatable, Sendable {
public let name: String?

View File

@@ -9,7 +9,7 @@ public struct Room: Codable, Equatable, Identifiable, Sendable {
public let coolingTotal: Double
public let coolingSensible: Double?
public let registerCount: Int
public let rectangularSizes: [DuctSizing.RectangularDuct]?
public let rectangularSizes: [RectangularSize]?
public let createdAt: Date
public let updatedAt: Date
@@ -21,7 +21,7 @@ public struct Room: Codable, Equatable, Identifiable, Sendable {
coolingTotal: Double,
coolingSensible: Double? = nil,
registerCount: Int = 1,
rectangularSizes: [DuctSizing.RectangularDuct]? = nil,
rectangularSizes: [RectangularSize]? = nil,
createdAt: Date,
updatedAt: Date
) {
@@ -65,13 +65,30 @@ extension Room {
}
}
public struct RectangularSize: Codable, Equatable, Identifiable, Sendable {
public let id: UUID
public let register: Int?
public let height: Int
public init(
id: UUID = .init(),
register: Int? = nil,
height: Int,
) {
self.id = id
self.register = register
self.height = height
}
}
public struct Update: Codable, Equatable, Sendable {
public let name: String?
public let heatingLoad: Double?
public let coolingTotal: Double?
public let coolingSensible: Double?
public let registerCount: Int?
public let rectangularSizes: [DuctSizing.RectangularDuct]?
public let rectangularSizes: [RectangularSize]?
public init(
name: String? = nil,
@@ -89,7 +106,7 @@ extension Room {
}
public init(
rectangularSizes: [DuctSizing.RectangularDuct]
rectangularSizes: [RectangularSize]
) {
self.name = nil
self.heatingLoad = nil
@@ -154,6 +171,86 @@ extension Array where Element == Room {
updatedAt: Date()
),
]
public static func mock(projectID: Project.ID) -> [Self] {
@Dependency(\.uuid) var uuid
@Dependency(\.date.now) var now
return [
.init(
id: uuid(),
projectID: projectID,
name: "Bed-1",
heatingLoad: 3913,
coolingTotal: 2472,
coolingSensible: nil,
registerCount: 1,
rectangularSizes: nil,
createdAt: now,
updatedAt: now
),
.init(
id: uuid(),
projectID: projectID,
name: "Entry",
heatingLoad: 8284,
coolingTotal: 2916,
coolingSensible: nil,
registerCount: 2,
rectangularSizes: nil,
createdAt: now,
updatedAt: now
),
.init(
id: uuid(),
projectID: projectID,
name: "Family Room",
heatingLoad: 9785,
coolingTotal: 7446,
coolingSensible: nil,
registerCount: 3,
rectangularSizes: nil,
createdAt: now,
updatedAt: now
),
.init(
id: uuid(),
projectID: projectID,
name: "Kitchen",
heatingLoad: 4518,
coolingTotal: 5096,
coolingSensible: nil,
registerCount: 2,
rectangularSizes: nil,
createdAt: now,
updatedAt: now
),
.init(
id: uuid(),
projectID: projectID,
name: "Living Room",
heatingLoad: 7553,
coolingTotal: 6829,
coolingSensible: nil,
registerCount: 2,
rectangularSizes: nil,
createdAt: now,
updatedAt: now
),
.init(
id: uuid(),
projectID: projectID,
name: "Master",
heatingLoad: 8202,
coolingTotal: 2076,
coolingSensible: nil,
registerCount: 2,
rectangularSizes: nil,
createdAt: now,
updatedAt: now
),
]
}
}
#endif

View File

@@ -88,11 +88,16 @@ extension SiteRoute.Api {
extension SiteRoute.Api.ProjectRoute {
public enum DetailRoute: Equatable, Sendable {
case index
case completedSteps
static let rootPath = "details"
static let router = OneOf {
Route(.case(Self.index)) {
Path { rootPath }
Method.get
}
Route(.case(Self.completedSteps)) {
Path {
rootPath

View File

@@ -146,6 +146,7 @@ extension SiteRoute.View.ProjectRoute {
case equipment(EquipmentInfoRoute)
case equivalentLength(EquivalentLengthRoute)
case frictionRate(FrictionRateRoute)
case pdf
case rooms(RoomRoute)
static let router = OneOf {
@@ -167,6 +168,10 @@ extension SiteRoute.View.ProjectRoute {
Route(.case(Self.frictionRate)) {
FrictionRateRoute.router
}
Route(.case(Self.pdf)) {
Path { "pdf" }
Method.get
}
Route(.case(Self.rooms)) {
RoomRoute.router
}
@@ -627,7 +632,7 @@ extension SiteRoute.View.ProjectRoute {
}
Method.delete
Query {
Field("rectangularSize") { DuctSizing.RectangularDuct.ID.parser() }
Field("rectangularSize") { Room.RectangularSize.ID.parser() }
Field("register") { Int.parser() }
}
.map(.memberwise(DeleteRectangularDuct.init))
@@ -642,7 +647,7 @@ extension SiteRoute.View.ProjectRoute {
Body {
FormData {
Optionally {
Field("id") { DuctSizing.RectangularDuct.ID.parser() }
Field("id") { Room.RectangularSize.ID.parser() }
}
Field("register") { Int.parser() }
Field("height") { Int.parser() }
@@ -658,19 +663,19 @@ extension SiteRoute.View.ProjectRoute {
public struct DeleteRectangularDuct: Equatable, Sendable {
public let rectangularSizeID: DuctSizing.RectangularDuct.ID
public let rectangularSizeID: Room.RectangularSize.ID
public let register: Int
public init(rectangularSizeID: DuctSizing.RectangularDuct.ID, register: Int) {
public init(rectangularSizeID: Room.RectangularSize.ID, register: Int) {
self.rectangularSizeID = rectangularSizeID
self.register = register
}
}
public enum TrunkRoute: Equatable, Sendable {
case delete(DuctSizing.TrunkSize.ID)
case delete(TrunkSize.ID)
case submit(TrunkSizeForm)
case update(DuctSizing.TrunkSize.ID, TrunkSizeForm)
case update(TrunkSize.ID, TrunkSizeForm)
public static let rootPath = "trunk"
@@ -678,7 +683,7 @@ extension SiteRoute.View.ProjectRoute {
Route(.case(Self.delete)) {
Path {
rootPath
DuctSizing.TrunkSize.ID.parser()
TrunkSize.ID.parser()
}
Method.delete
}
@@ -690,7 +695,7 @@ extension SiteRoute.View.ProjectRoute {
Body {
FormData {
Field("projectID") { Project.ID.parser() }
Field("type") { DuctSizing.TrunkSize.TrunkType.parser() }
Field("type") { TrunkSize.TrunkType.parser() }
Optionally {
Field("height") { Int.parser() }
@@ -708,13 +713,13 @@ extension SiteRoute.View.ProjectRoute {
Route(.case(Self.update)) {
Path {
rootPath
DuctSizing.TrunkSize.ID.parser()
TrunkSize.ID.parser()
}
Method.patch
Body {
FormData {
Field("projectID") { Project.ID.parser() }
Field("type") { DuctSizing.TrunkSize.TrunkType.parser() }
Field("type") { TrunkSize.TrunkType.parser() }
Optionally {
Field("height") { Int.parser() }
}
@@ -732,17 +737,43 @@ extension SiteRoute.View.ProjectRoute {
}
public struct RoomRectangularForm: Equatable, Sendable {
public let id: DuctSizing.RectangularDuct.ID?
public let id: Room.RectangularSize.ID?
public let register: Int
public let height: Int
public init(
id: Room.RectangularSize.ID? = nil,
register: Int,
height: Int
) {
self.id = id
self.register = register
self.height = height
}
}
public struct TrunkSizeForm: Equatable, Sendable {
public let projectID: Project.ID
public let type: DuctSizing.TrunkSize.TrunkType
public let type: TrunkSize.TrunkType
public let height: Int?
public let name: String?
public let rooms: [String]
public init(
projectID: Project.ID,
type: TrunkSize.TrunkType,
height: Int? = nil,
name: String? = nil,
rooms: [String]
) {
self.projectID = projectID
self.type = type
self.height = height
self.name = name
self.rooms = rooms
}
}
}
}

View File

@@ -0,0 +1,114 @@
import Dependencies
import Foundation
// Represents the database model.
public struct TrunkSize: Codable, Equatable, Identifiable, Sendable {
public let id: UUID
public let projectID: Project.ID
public let type: TrunkType
public let rooms: [RoomProxy]
public let height: Int?
public let name: String?
public init(
id: UUID,
projectID: Project.ID,
type: TrunkType,
rooms: [RoomProxy],
height: Int? = nil,
name: String? = nil
) {
self.id = id
self.projectID = projectID
self.type = type
self.rooms = rooms
self.height = height
self.name = name
}
}
extension TrunkSize {
public struct Create: Codable, Equatable, Sendable {
public let projectID: Project.ID
public let type: TrunkType
public let rooms: [Room.ID: [Int]]
public let height: Int?
public let name: String?
public init(
projectID: Project.ID,
type: TrunkType,
rooms: [Room.ID: [Int]],
height: Int? = nil,
name: String? = nil
) {
self.projectID = projectID
self.type = type
self.rooms = rooms
self.height = height
self.name = name
}
}
public struct Update: Codable, Equatable, Sendable {
public let type: TrunkType?
public let rooms: [Room.ID: [Int]]?
public let height: Int?
public let name: String?
public init(
type: TrunkType? = nil,
rooms: [Room.ID: [Int]]? = nil,
height: Int? = nil,
name: String? = nil
) {
self.type = type
self.rooms = rooms
self.height = height
self.name = name
}
}
public struct RoomProxy: Codable, Equatable, Identifiable, Sendable {
public var id: Room.ID { room.id }
public let room: Room
public let registers: [Int]
public init(room: Room, registers: [Int]) {
self.room = room
self.registers = registers
}
}
public enum TrunkType: String, CaseIterable, Codable, Equatable, Sendable {
case `return`
case supply
public static let allCases = [Self.supply, .return]
}
}
#if DEBUG
extension TrunkSize {
public static func mock(projectID: Project.ID, rooms: [Room]) -> [Self] {
@Dependency(\.uuid) var uuid
let allRooms = rooms.reduce(into: [TrunkSize.RoomProxy]()) { array, room in
var registers = [Int]()
for n in 1...room.registerCount {
registers.append(n)
}
array.append(.init(room: room, registers: registers))
}
return [
.init(id: uuid(), projectID: projectID, type: .supply, rooms: allRooms),
.init(id: uuid(), projectID: projectID, type: .return, rooms: allRooms),
]
}
}
#endif

View File

@@ -0,0 +1,93 @@
import Dependencies
import DependenciesMacros
import Elementary
import ManualDCore
extension DependencyValues {
public var pdfClient: PdfClient {
get { self[PdfClient.self] }
set { self[PdfClient.self] = newValue }
}
}
@DependencyClient
public struct PdfClient: Sendable {
public var html: @Sendable (Request) async throws -> (any HTML & Sendable)
public var markdown: @Sendable (Request) async throws -> String
}
extension PdfClient: DependencyKey {
public static let testValue = Self()
public static let liveValue = Self(
html: { request in
request.toHTML()
},
markdown: { request in
request.toMarkdown()
}
)
}
extension PdfClient {
public struct Request: Codable, Equatable, Sendable {
public let project: Project
public let rooms: [Room]
public let componentLosses: [ComponentPressureLoss]
public let ductSizes: DuctSizes
public let equipmentInfo: EquipmentInfo
public let maxSupplyTEL: EffectiveLength
public let maxReturnTEL: EffectiveLength
public let frictionRate: FrictionRate
public let projectSHR: Double
public init(
project: Project,
rooms: [Room],
componentLosses: [ComponentPressureLoss],
ductSizes: DuctSizes,
equipmentInfo: EquipmentInfo,
maxSupplyTEL: EffectiveLength,
maxReturnTEL: EffectiveLength,
frictionRate: FrictionRate,
projectSHR: Double
) {
self.project = project
self.rooms = rooms
self.componentLosses = componentLosses
self.ductSizes = ductSizes
self.equipmentInfo = equipmentInfo
self.maxSupplyTEL = maxSupplyTEL
self.maxReturnTEL = maxReturnTEL
self.frictionRate = frictionRate
self.projectSHR = projectSHR
}
}
}
#if DEBUG
extension PdfClient.Request {
public static func mock(project: Project = .mock) -> Self {
let rooms = Room.mock(projectID: project.id)
let trunks = TrunkSize.mock(projectID: project.id, rooms: rooms)
let equipmentInfo = EquipmentInfo.mock(projectID: project.id)
let equivalentLengths = EffectiveLength.mock(projectID: project.id)
return .init(
project: project,
rooms: rooms,
componentLosses: ComponentPressureLoss.mock(projectID: project.id),
ductSizes: .mock(equipmentInfo: equipmentInfo, rooms: rooms, trunks: trunks),
equipmentInfo: equipmentInfo,
maxSupplyTEL: equivalentLengths.first { $0.type == .supply }!,
maxReturnTEL: equivalentLengths.first { $0.type == .return }!,
frictionRate: .mock,
projectSHR: 0.83
)
}
}
#endif

View File

@@ -0,0 +1,108 @@
import Elementary
import ManualDCore
extension PdfClient.Request {
func toHTML() -> (some HTML & Sendable) {
PdfDocument(request: self)
}
}
struct PdfDocument: HTMLDocument {
let title = "Duct Calc"
let lang = "en"
let request: PdfClient.Request
var head: some HTML {
link(.rel(.stylesheet), .href("/css/pdf.css"))
}
var body: some HTML {
div {
h1(.class("headline")) { "Duct Calc" }
h2 { "Project" }
div(.class("flex")) {
ProjectTable(project: request.project)
// HACK:
table {}
}
div(.class("section")) {
div(.class("flex")) {
h2 { "Equipment" }
h2 { "Friction Rate" }
}
div(.class("flex")) {
div(.class("container")) {
div(.class("table-container")) {
EquipmentTable(title: "Equipment", equipmentInfo: request.equipmentInfo)
}
div(.class("table-container")) {
FrictionRateTable(
title: "Friction Rate",
componentLosses: request.componentLosses,
frictionRate: request.frictionRate,
totalEquivalentLength: request.totalEquivalentLength,
displayTotals: false
)
}
}
}
if let error = request.frictionRate.error {
div(.class("section")) {
p(.class("error")) {
error.reason
for resolution in error.resolutions {
br()
" * \(resolution)"
}
}
}
}
}
div(.class("section")) {
h2 { "Duct Sizes" }
DuctSizesTable(rooms: request.ductSizes.rooms)
.attributes(.class("w-full"))
}
div(.class("section")) {
h2 { "Supply Trunk / Run Outs" }
TrunkTable(sizes: request.ductSizes, type: .supply)
.attributes(.class("w-full"))
}
div(.class("section")) {
h2 { "Return Trunk / Run Outs" }
TrunkTable(sizes: request.ductSizes, type: .return)
.attributes(.class("w-full"))
}
div(.class("section")) {
h2 { "Total Equivalent Lengths" }
EffectiveLengthsTable(effectiveLengths: [
request.maxSupplyTEL, request.maxReturnTEL,
])
.attributes(.class("w-full"))
}
div(.class("section")) {
h2 { "Register Detail" }
RegisterDetailTable(rooms: request.ductSizes.rooms)
.attributes(.class("w-full"))
}
div(.class("section")) {
h2 { "Room Detail" }
RoomsTable(rooms: request.rooms, projectSHR: request.projectSHR)
.attributes(.class("w-full"))
}
}
}
}

View File

@@ -0,0 +1,113 @@
import Foundation
import ManualDCore
extension PdfClient.Request {
func toMarkdown() -> String {
var retval = """
# Duct Calc
**Name:** \(project.name)
**Address:** \(project.streetAddress)
\(project.city), \(project.state) \(project.zipCode)
## Equipment
| | Value |
|:----------------|:--------------------------------|
| Static Pressure | \(equipmentInfo.staticPressure.string()) |
| Heating CFM | \(equipmentInfo.heatingCFM.string()) |
| Cooling CFM | \(equipmentInfo.coolingCFM.string()) |
## Friction Rate
| Component Loss | Value |
|:----------------|:--------------------------------|
"""
for row in componentLosses {
retval += "\(componentLossRow(row))\n"
}
retval += """
| Results | Value |
|:-----------------|:---------------------------------|
| Available Static Pressure | \(frictionRate.availableStaticPressure.string()) |
| Total Equivalent Length | \(totalEquivalentLength.string()) |
| Friction Rate Design Value | \(frictionRate.value.string()) |
## Duct Sizes
| Register | Dsn CFM | Round Size | Velocity | Final Size | Flex Size | Height | Width |
|:---------|:--------|:----------------|:---------|:-----------|:----------|:-------|:------|
"""
for row in ductSizes.rooms {
retval += "\(registerRow(row))\n"
}
retval += """
## Trunk Sizes
### Supply Trunks
| Name | Associated Supplies | Dsn CFM | Velocity | Final Size | Flex Size | Height | Width |
|:---------|:--------------------|:--------|:---------|:-----------|:----------|:-------|:------|
"""
for row in ductSizes.trunks.filter({ $0.type == .supply }) {
retval += "\(trunkRow(row))\n"
}
retval += """
### Return Trunks / Run Outs
| Name | Associated Supplies | Dsn CFM | Velocity | Final Size | Flex Size | Height | Width |
|:---------|:--------------------|:--------|:---------|:-----------|:----------|:-------|:------|
"""
for row in ductSizes.trunks.filter({ $0.type == .return }) {
retval += "\(trunkRow(row))\n"
}
return retval
}
func registerRow(_ row: DuctSizes.RoomContainer) -> String {
return """
| \(row.roomName) | \(row.designCFM.value.string(digits: 0)) | \(row.roundSize.string()) | \(row.velocity.string()) | \(row.finalSize.string()) | \(row.flexSize.string()) | \(row.height?.string() ?? "") | \(row.width?.string() ?? "") |
"""
}
func trunkRow(_ row: DuctSizes.TrunkContainer) -> String {
return """
| \(row.name ?? "") | \(associatedSupplyString(row)) | \(row.designCFM.value.string(digits: 0)) | \(row.roundSize.string()) | \(row.velocity.string()) | \(row.finalSize.string()) | \(row.flexSize.string()) | \(row.ductSize.height?.string() ?? "") | \(row.width?.string() ?? "") |
"""
}
func componentLossRow(_ row: ComponentPressureLoss) -> String {
return """
| \(row.name) | \(row.value.string()) |
"""
}
var totalEquivalentLength: Double {
maxSupplyTEL.totalEquivalentLength + maxReturnTEL.totalEquivalentLength
}
func associatedSupplyString(_ row: DuctSizes.TrunkContainer) -> String {
row.associatedSupplyString(rooms: ductSizes.rooms)
}
}
extension DuctSizes.TrunkContainer {
func associatedSupplyString(rooms: [DuctSizes.RoomContainer]) -> String {
self.registerIDS(rooms: rooms)
.joined(separator: ", ")
}
}

View File

@@ -0,0 +1,37 @@
import Elementary
import ManualDCore
struct DuctSizesTable: HTML, Sendable {
let rooms: [DuctSizes.RoomContainer]
var body: some HTML<HTMLTag.table> {
table {
thead {
tr(.class("bg-green")) {
th { "Name" }
th { "Dsn CFM" }
th { "Round Size" }
th { "Velocity" }
th { "Final Size" }
th { "Flex Size" }
th { "Height" }
th { "Width" }
}
}
tbody {
for row in rooms {
tr {
td { row.roomName }
td { row.designCFM.value.string(digits: 0) }
td { row.roundSize.string() }
td { row.velocity.string() }
td { row.flexSize.string() }
td { row.finalSize.string() }
td { row.ductSize.height?.string() ?? "" }
td { row.width?.string() ?? "" }
}
}
}
}
}
}

View File

@@ -0,0 +1,38 @@
import Elementary
import ManualDCore
struct EquipmentTable: HTML, Sendable {
let title: String?
let equipmentInfo: EquipmentInfo
init(title: String? = nil, equipmentInfo: EquipmentInfo) {
self.title = title
self.equipmentInfo = equipmentInfo
}
var body: some HTML<HTMLTag.table> {
table {
thead {
tr(.class("bg-green")) {
th { title ?? "" }
th(.class("justify-end")) { "Value" }
}
}
tbody {
tr {
td { "Static Pressure" }
td(.class("justify-end")) { equipmentInfo.staticPressure.string() }
}
tr {
td { "Heating CFM" }
td(.class("justify-end")) { equipmentInfo.heatingCFM.string() }
}
tr {
td { "Cooling CFM" }
td(.class("justify-end")) { equipmentInfo.coolingCFM.string() }
}
}
}
}
}

View File

@@ -0,0 +1,68 @@
import Elementary
import ManualDCore
struct EffectiveLengthsTable: HTML, Sendable {
let effectiveLengths: [EffectiveLength]
var body: some HTML<HTMLTag.table> {
table {
thead {
tr(.class("bg-green")) {
th { "Name" }
th { "Type" }
th { "Straight Lengths" }
th { "Groups" }
th { "Total" }
}
}
tbody {
for row in effectiveLengths {
tr {
td { row.name }
td { row.type.rawValue }
td {
ul {
for length in row.straightLengths {
li { length.string() }
}
}
}
td {
EffectiveLengthGroupTable(groups: row.groups)
.attributes(.class("w-full"))
}
td { row.totalEquivalentLength.string(digits: 0) }
}
}
}
}
}
}
struct EffectiveLengthGroupTable: HTML, Sendable {
let groups: [EffectiveLength.Group]
var body: some HTML<HTMLTag.table> {
table {
thead {
tr(.class("effectiveLengthGroupHeader")) {
th { "Name" }
th { "Length" }
th { "Quantity" }
th { "Total" }
}
}
tbody {
for row in groups {
tr {
td { "\(row.group)-\(row.letter)" }
td { row.value.string(digits: 0) }
td { row.quantity.string() }
td { (row.value * Double(row.quantity)).string(digits: 0) }
}
}
}
}
}
}

View File

@@ -0,0 +1,47 @@
import Elementary
import ManualDCore
struct FrictionRateTable: HTML, Sendable {
let title: String?
let componentLosses: [ComponentPressureLoss]
let frictionRate: FrictionRate
let totalEquivalentLength: Double
let displayTotals: Bool
var sortedLosses: [ComponentPressureLoss] {
componentLosses.sorted { $0.value > $1.value }
}
var body: some HTML<HTMLTag.table> {
table {
thead {
tr(.class("bg-green")) {
th { title ?? "" }
th(.class("justify-end")) { "Value" }
}
}
tbody {
for row in sortedLosses {
tr {
td { row.name }
td(.class("justify-end")) { row.value.string() }
}
}
if displayTotals {
tr {
td(.class("label justify-end")) { "Available Static Pressure" }
td(.class("justify-end")) { frictionRate.availableStaticPressure.string() }
}
tr {
td(.class("label justify-end")) { "Total Equivalent Length" }
td(.class("justify-end")) { totalEquivalentLength.string() }
}
tr {
td(.class("label justify-end")) { "Friction Rate Design Value" }
td(.class("justify-end")) { frictionRate.value.string() }
}
}
}
}
}
}

View File

@@ -0,0 +1,33 @@
import Elementary
import ManualDCore
struct ProjectTable: HTML, Sendable {
let project: Project
var body: some HTML<HTMLTag.table> {
table {
tbody {
tr {
td(.class("label")) { "Name" }
td { project.name }
}
tr {
td(.class("label")) { "Address" }
td {
p {
project.streetAddress
br()
project.cityStateZipString
}
}
}
}
}
}
}
extension Project {
var cityStateZipString: String {
return "\(city), \(state) \(zipCode)"
}
}

View File

@@ -0,0 +1,33 @@
import Elementary
import ManualDCore
struct RegisterDetailTable: HTML, Sendable {
let rooms: [DuctSizes.RoomContainer]
var body: some HTML<HTMLTag.table> {
table {
thead {
tr(.class("bg-green")) {
th { "Name" }
th { "Heating BTU" }
th { "Cooling BTU" }
th { "Heating CFM" }
th { "Cooling CFM" }
th { "Design CFM" }
}
}
tbody {
for row in rooms {
tr {
td { row.roomName }
td { row.heatingLoad.string(digits: 0) }
td { row.coolingLoad.string(digits: 0) }
td { row.heatingCFM.string(digits: 0) }
td { row.coolingCFM.string(digits: 0) }
td { row.designCFM.value.string(digits: 0) }
}
}
}
}
}
}

View File

@@ -0,0 +1,50 @@
import Elementary
import ManualDCore
struct RoomsTable: HTML, Sendable {
let rooms: [Room]
let projectSHR: Double
var body: some HTML<HTMLTag.table> {
table {
thead {
tr(.class("bg-green")) {
th { "Name" }
th { "Heating BTU" }
th { "Cooling Total BTU" }
th { "Cooling Sensible BTU" }
th { "Register Count" }
}
}
tbody {
for room in rooms {
tr {
td { room.name }
td { room.heatingLoad.string(digits: 0) }
td { room.coolingTotal.string(digits: 0) }
td {
(room.coolingSensible
?? (room.coolingTotal * projectSHR)).string(digits: 0)
}
td { room.registerCount.string() }
}
}
// Totals
// tr(.class("table-footer")) {
tr {
td(.class("label")) { "Totals" }
td(.class("heating label")) {
rooms.totalHeatingLoad.string(digits: 0)
}
td(.class("coolingTotal label")) {
rooms.totalCoolingLoad.string(digits: 0)
}
td(.class("coolingSensible label")) {
rooms.totalCoolingSensible(shr: projectSHR).string(digits: 0)
}
td {}
}
}
}
}
}

View File

@@ -0,0 +1,42 @@
import Elementary
import ManualDCore
struct TrunkTable: HTML, Sendable {
public let sizes: DuctSizes
public let type: TrunkSize.TrunkType
var trunks: [DuctSizes.TrunkContainer] {
sizes.trunks.filter { $0.type == type }
}
var body: some HTML<HTMLTag.table> {
table {
thead(.class("bg-green")) {
tr {
th { "Name" }
th { "Dsn CFM" }
th { "Round Size" }
th { "Velocity" }
th { "Final Size" }
th { "Flex Size" }
th { "Height" }
th { "Width" }
}
}
tbody {
for row in trunks {
tr {
td { row.name ?? "" }
td { row.designCFM.value.string(digits: 0) }
td { row.ductSize.roundSize.string() }
td { row.velocity.string() }
td { row.finalSize.string() }
td { row.flexSize.string() }
td { row.ductSize.height?.string() ?? "" }
td { row.width?.string() ?? "" }
}
}
}
}
}
}

View File

@@ -0,0 +1,9 @@
import Foundation
public struct ProjectClientError: Error {
public let reason: String
public init(_ reason: String) {
self.reason = reason
}
}

View File

@@ -0,0 +1,78 @@
import Dependencies
import DependenciesMacros
import Elementary
import ManualDClient
import ManualDCore
extension DependencyValues {
public var projectClient: ProjectClient {
get { self[ProjectClient.self] }
set { self[ProjectClient.self] = newValue }
}
}
/// Useful helper utilities for project's.
///
/// This is primarily used for implementing logic required to get the needed data
/// for the view controller client to render views.
@DependencyClient
public struct ProjectClient: Sendable {
public var calculateDuctSizes: @Sendable (Project.ID) async throws -> DuctSizes
public var calculateRoomDuctSizes:
@Sendable (Project.ID) async throws -> [DuctSizes.RoomContainer]
public var calculateTrunkDuctSizes:
@Sendable (Project.ID) async throws -> [DuctSizes.TrunkContainer]
public var createProject:
@Sendable (User.ID, Project.Create) async throws -> CreateProjectResponse
public var frictionRate: @Sendable (Project.ID) async throws -> FrictionRateResponse
// FIX: Name to something to do with generating a pdf, just experimenting now.
public var toMarkdown: @Sendable (Project.ID) async throws -> String
public var toHTML: @Sendable (Project.ID) async throws -> (any HTML & Sendable)
}
extension ProjectClient: TestDependencyKey {
public static let testValue = Self()
}
extension ProjectClient {
public struct CreateProjectResponse: Codable, Equatable, Sendable {
public let projectID: Project.ID
public let rooms: [Room]
public let sensibleHeatRatio: Double?
public let completedSteps: Project.CompletedSteps
public init(
projectID: Project.ID,
rooms: [Room],
sensibleHeatRatio: Double? = nil,
completedSteps: Project.CompletedSteps
) {
self.projectID = projectID
self.rooms = rooms
self.sensibleHeatRatio = sensibleHeatRatio
self.completedSteps = completedSteps
}
}
public struct FrictionRateResponse: Codable, Equatable, Sendable {
public let componentLosses: [ComponentPressureLoss]
public let equivalentLengths: EffectiveLength.MaxContainer
public let frictionRate: FrictionRate?
public init(
componentLosses: [ComponentPressureLoss],
equivalentLengths: EffectiveLength.MaxContainer,
frictionRate: FrictionRate? = nil
) {
self.componentLosses = componentLosses
self.equivalentLengths = equivalentLengths
self.frictionRate = frictionRate
}
}
}

View File

@@ -0,0 +1,12 @@
import DatabaseClient
import ManualDCore
extension DatabaseClient.ComponentLoss {
func createDefaults(projectID: Project.ID) async throws {
let defaults = ComponentPressureLoss.Create.default(projectID: projectID)
for loss in defaults {
_ = try await create(loss)
}
}
}

View File

@@ -0,0 +1,200 @@
import DatabaseClient
import Dependencies
import ManualDClient
import ManualDCore
extension DatabaseClient {
func calculateDuctSizes(
details: Project.Detail
) async throws -> (DuctSizes, DuctSizeSharedRequest) {
let (rooms, shared) = try await calculateRoomDuctSizes(details: details)
let (trunks, _) = try await calculateTrunkDuctSizes(details: details)
return (.init(rooms: rooms, trunks: trunks), shared)
}
func calculateDuctSizes(
projectID: Project.ID
) async throws -> (DuctSizes, DuctSizeSharedRequest, [Room]) {
@Dependency(\.manualD) var manualD
let shared = try await sharedDuctRequest(projectID)
let rooms = try await rooms.fetch(projectID)
return try await (
manualD.calculateDuctSizes(
rooms: rooms,
trunks: trunkSizes.fetch(projectID),
sharedRequest: shared
),
shared,
rooms
)
}
func calculateRoomDuctSizes(
details: Project.Detail
) async throws -> ([DuctSizes.RoomContainer], DuctSizeSharedRequest) {
@Dependency(\.manualD) var manualD
let shared = try sharedDuctRequest(details: details)
let rooms = try await manualD.calculateRoomSizes(rooms: details.rooms, sharedRequest: shared)
return (rooms, shared)
}
func calculateRoomDuctSizes(
projectID: Project.ID
) async throws -> ([DuctSizes.RoomContainer], DuctSizeSharedRequest) {
@Dependency(\.manualD) var manualD
let shared = try await sharedDuctRequest(projectID)
return try await (
manualD.calculateRoomSizes(
rooms: rooms.fetch(projectID),
sharedRequest: shared
),
shared
)
}
func calculateTrunkDuctSizes(
details: Project.Detail
) async throws -> ([DuctSizes.TrunkContainer], DuctSizeSharedRequest) {
@Dependency(\.manualD) var manualD
let shared = try sharedDuctRequest(details: details)
let trunks = try await manualD.calculateTrunkSizes(
rooms: details.rooms,
trunks: details.trunks,
sharedRequest: shared
)
return (trunks, shared)
}
func calculateTrunkDuctSizes(
projectID: Project.ID
) async throws -> ([DuctSizes.TrunkContainer], DuctSizeSharedRequest) {
@Dependency(\.manualD) var manualD
let shared = try await sharedDuctRequest(projectID)
return try await (
manualD.calculateTrunkSizes(
rooms: rooms.fetch(projectID),
trunks: trunkSizes.fetch(projectID),
sharedRequest: shared
),
shared
)
}
func sharedDuctRequest(details: Project.Detail) throws -> DuctSizeSharedRequest {
guard
let dfrResponse = designFrictionRate(
componentLosses: details.componentLosses,
equipmentInfo: details.equipmentInfo,
equivalentLengths: details.maxContainer
)
else {
throw ProjectClientError("Project not complete.")
}
guard let projectSHR = details.project.sensibleHeatRatio else {
throw ProjectClientError("Project sensible heat ratio not set.")
}
let ensuredTEL = try dfrResponse.ensureMaxContainer()
return .init(
equipmentInfo: dfrResponse.equipmentInfo,
maxSupplyLength: ensuredTEL.supply,
maxReturnLenght: ensuredTEL.return,
designFrictionRate: dfrResponse.designFrictionRate,
projectSHR: projectSHR
)
}
func sharedDuctRequest(_ projectID: Project.ID) async throws -> DuctSizeSharedRequest {
guard let dfrResponse = try await designFrictionRate(projectID: projectID) else {
throw ProjectClientError("Project not complete.")
}
let ensuredTEL = try dfrResponse.ensureMaxContainer()
return try await .init(
equipmentInfo: dfrResponse.equipmentInfo,
maxSupplyLength: ensuredTEL.supply,
maxReturnLenght: ensuredTEL.return,
designFrictionRate: dfrResponse.designFrictionRate,
projectSHR: ensuredSHR(projectID)
)
}
// Fetches the project sensible heat ratio or throws an error if it's nil.
func ensuredSHR(_ projectID: Project.ID) async throws -> Double {
guard let projectSHR = try await projects.getSensibleHeatRatio(projectID) else {
throw ProjectClientError("Project sensible heat ratio not set.")
}
return projectSHR
}
// Internal container.
struct DesignFrictionRateResponse: Equatable, Sendable {
typealias EnsuredTEL = (supply: EffectiveLength, return: EffectiveLength)
let designFrictionRate: Double
let equipmentInfo: EquipmentInfo
let telMaxContainer: EffectiveLength.MaxContainer
func ensureMaxContainer() throws -> EnsuredTEL {
guard let maxSupplyLength = telMaxContainer.supply else {
throw ProjectClientError("Max supply TEL not found")
}
guard let maxReturnLength = telMaxContainer.return else {
throw ProjectClientError("Max supply TEL not found")
}
return (maxSupplyLength, maxReturnLength)
}
}
func designFrictionRate(
componentLosses: [ComponentPressureLoss],
equipmentInfo: EquipmentInfo,
equivalentLengths: EffectiveLength.MaxContainer
) -> DesignFrictionRateResponse? {
guard let tel = equivalentLengths.total,
componentLosses.count > 0
else { return nil }
let availableStaticPressure = equipmentInfo.staticPressure - componentLosses.total
return .init(
designFrictionRate: (availableStaticPressure * 100) / tel,
equipmentInfo: equipmentInfo,
telMaxContainer: equivalentLengths
)
}
func designFrictionRate(
projectID: Project.ID
) async throws -> DesignFrictionRateResponse? {
guard let equipmentInfo = try await equipment.fetch(projectID) else {
return nil
}
return try await designFrictionRate(
componentLosses: componentLoss.fetch(projectID),
equipmentInfo: equipmentInfo,
equivalentLengths: effectiveLength.fetchMax(projectID)
)
}
}

View File

@@ -0,0 +1,223 @@
import Logging
import ManualDClient
import ManualDCore
struct DuctSizeSharedRequest {
let equipmentInfo: EquipmentInfo
let maxSupplyLength: EffectiveLength
let maxReturnLenght: EffectiveLength
let designFrictionRate: Double
let projectSHR: Double
}
// TODO: Remove Logger and use depedency logger.
extension ManualDClient {
func calculateDuctSizes(
rooms: [Room],
trunks: [TrunkSize],
sharedRequest: DuctSizeSharedRequest,
logger: Logger? = nil
) async throws -> DuctSizes {
try await .init(
rooms: calculateRoomSizes(
rooms: rooms,
sharedRequest: sharedRequest
),
trunks: calculateTrunkSizes(
rooms: rooms,
trunks: trunks,
sharedRequest: sharedRequest
)
)
}
func calculateRoomSizes(
rooms: [Room],
sharedRequest: DuctSizeSharedRequest,
logger: Logger? = nil
) async throws -> [DuctSizes.RoomContainer] {
var retval: [DuctSizes.RoomContainer] = []
let totalHeatingLoad = rooms.totalHeatingLoad
let totalCoolingSensible = rooms.totalCoolingSensible(shr: sharedRequest.projectSHR)
for room in rooms {
let heatingLoad = room.heatingLoadPerRegister
let coolingLoad = room.coolingSensiblePerRegister(projectSHR: sharedRequest.projectSHR)
let heatingPercent = heatingLoad / totalHeatingLoad
let coolingPercent = coolingLoad / totalCoolingSensible
let heatingCFM = heatingPercent * Double(sharedRequest.equipmentInfo.heatingCFM)
let coolingCFM = coolingPercent * Double(sharedRequest.equipmentInfo.coolingCFM)
let designCFM = DuctSizes.DesignCFM(heating: heatingCFM, cooling: coolingCFM)
let sizes = try await self.ductSize(
.init(designCFM: Int(designCFM.value), frictionRate: sharedRequest.designFrictionRate)
)
for n in 1...room.registerCount {
var rectangularWidth: Int? = nil
let rectangularSize = room.rectangularSizes?
.first(where: { $0.register == nil || $0.register == n })
if let rectangularSize {
let response = try await self.rectangularSize(
.init(round: sizes.finalSize, height: rectangularSize.height)
)
rectangularWidth = response.width
}
retval.append(
.init(
roomID: room.id,
roomName: "\(room.name)-\(n)",
roomRegister: n,
heatingLoad: heatingLoad,
coolingLoad: coolingLoad,
heatingCFM: heatingCFM,
coolingCFM: coolingCFM,
ductSize: .init(
designCFM: designCFM,
sizes: sizes,
rectangularSize: rectangularSize,
width: rectangularWidth
)
)
)
}
}
return retval
}
func calculateTrunkSizes(
rooms: [Room],
trunks: [TrunkSize],
sharedRequest: DuctSizeSharedRequest,
logger: Logger? = nil
) async throws -> [DuctSizes.TrunkContainer] {
var retval = [DuctSizes.TrunkContainer]()
let totalHeatingLoad = rooms.totalHeatingLoad
let totalCoolingSensible = rooms.totalCoolingSensible(shr: sharedRequest.projectSHR)
for trunk in trunks {
let heatingLoad = trunk.totalHeatingLoad
let coolingLoad = trunk.totalCoolingSensible(projectSHR: sharedRequest.projectSHR)
let heatingPercent = heatingLoad / totalHeatingLoad
let coolingPercent = coolingLoad / totalCoolingSensible
let heatingCFM = heatingPercent * Double(sharedRequest.equipmentInfo.heatingCFM)
let coolingCFM = coolingPercent * Double(sharedRequest.equipmentInfo.coolingCFM)
let designCFM = DuctSizes.DesignCFM(heating: heatingCFM, cooling: coolingCFM)
let sizes = try await self.ductSize(
.init(designCFM: Int(designCFM.value), frictionRate: sharedRequest.designFrictionRate)
)
var width: Int? = nil
if let height = trunk.height {
let rectangularSize = try await self.rectangularSize(
.init(round: sizes.finalSize, height: height)
)
width = rectangularSize.width
}
retval.append(
.init(
trunk: trunk,
ductSize: .init(
designCFM: designCFM,
sizes: sizes,
height: trunk.height,
width: width
)
)
)
}
return retval
}
}
extension DuctSizes.SizeContainer {
init(
designCFM: DuctSizes.DesignCFM,
sizes: ManualDClient.DuctSizeResponse,
height: Int?,
width: Int?
) {
self.init(
rectangularID: nil,
designCFM: designCFM,
roundSize: sizes.calculatedSize,
finalSize: sizes.finalSize,
velocity: sizes.velocity,
flexSize: sizes.flexSize,
height: height,
width: width
)
}
init(
designCFM: DuctSizes.DesignCFM,
sizes: ManualDClient.DuctSizeResponse,
rectangularSize: Room.RectangularSize?,
width: Int?
) {
self.init(
rectangularID: rectangularSize?.id,
designCFM: designCFM,
roundSize: sizes.calculatedSize,
finalSize: sizes.finalSize,
velocity: sizes.velocity,
flexSize: sizes.flexSize,
height: rectangularSize?.height,
width: width
)
}
}
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 {
// We need to make sure if registers got removed after a trunk
// was already made / saved that we do not include registers that
// no longer exist.
private var actualRegisterCount: Int {
guard registers.count <= room.registerCount else {
return room.registerCount
}
return registers.count
}
var totalHeatingLoad: Double {
room.heatingLoadPerRegister * Double(actualRegisterCount)
}
func totalCoolingSensible(projectSHR: Double) -> Double {
room.coolingSensiblePerRegister(projectSHR: projectSHR) * Double(actualRegisterCount)
}
}
extension 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) }
}
}

View File

@@ -0,0 +1,55 @@
import DatabaseClient
import Dependencies
import ManualDClient
import ManualDCore
extension ManualDClient {
func frictionRate(details: Project.Detail) async throws -> ProjectClient.FrictionRateResponse {
let maxContainer = details.maxContainer
guard let totalEquivalentLength = maxContainer.total else {
return .init(componentLosses: details.componentLosses, equivalentLengths: maxContainer)
}
return try await .init(
componentLosses: details.componentLosses,
equivalentLengths: maxContainer,
frictionRate: frictionRate(
.init(
externalStaticPressure: details.equipmentInfo.staticPressure,
componentPressureLosses: details.componentLosses,
totalEffectiveLength: Int(totalEquivalentLength)
)
)
)
}
func frictionRate(projectID: Project.ID) async throws -> ProjectClient.FrictionRateResponse {
@Dependency(\.database) var database
let componentLosses = try await database.componentLoss.fetch(projectID)
let lengths = try await database.effectiveLength.fetchMax(projectID)
let equipmentInfo = try await database.equipment.fetch(projectID)
guard let staticPressure = equipmentInfo?.staticPressure else {
return .init(componentLosses: componentLosses, equivalentLengths: lengths)
}
guard let totalEquivalentLength = lengths.total else {
return .init(componentLosses: componentLosses, equivalentLengths: lengths)
}
return try await .init(
componentLosses: componentLosses,
equivalentLengths: lengths,
frictionRate: frictionRate(
.init(
externalStaticPressure: staticPressure,
componentPressureLosses: database.componentLoss.fetch(projectID),
totalEffectiveLength: Int(totalEquivalentLength)
)
)
)
}
}

View File

@@ -0,0 +1,14 @@
import ManualDCore
extension Project.Detail {
var maxContainer: EffectiveLength.MaxContainer {
.init(
supply: equivalentLengths.filter({ $0.type == .supply })
.sorted(by: { $0.totalEquivalentLength > $1.totalEquivalentLength })
.first,
return: equivalentLengths.filter({ $0.type == .return })
.sorted(by: { $0.totalEquivalentLength > $1.totalEquivalentLength })
.first
)
}
}

View File

@@ -0,0 +1,119 @@
import DatabaseClient
import Dependencies
import Logging
import ManualDClient
import ManualDCore
import PdfClient
extension ProjectClient: DependencyKey {
public static var liveValue: Self {
@Dependency(\.database) var database
@Dependency(\.manualD) var manualD
@Dependency(\.pdfClient) var pdfClient
return .init(
calculateDuctSizes: { projectID in
try await database.calculateDuctSizes(projectID: projectID).0
},
calculateRoomDuctSizes: { projectID in
try await database.calculateRoomDuctSizes(projectID: projectID).0
},
calculateTrunkDuctSizes: { projectID in
try await database.calculateTrunkDuctSizes(projectID: projectID).0
},
createProject: { userID, request in
let project = try await database.projects.create(userID, request)
try await database.componentLoss.createDefaults(projectID: project.id)
return try await .init(
projectID: project.id,
rooms: database.rooms.fetch(project.id),
sensibleHeatRatio: database.projects.getSensibleHeatRatio(project.id),
completedSteps: database.projects.getCompletedSteps(project.id)
)
},
frictionRate: { projectID in
try await manualD.frictionRate(projectID: projectID)
},
toMarkdown: { projectID in
try await pdfClient.markdown(database.makePdfRequest(projectID))
},
toHTML: { projectID in
try await pdfClient.html(database.makePdfRequest(projectID))
}
)
}
}
extension DatabaseClient {
// fileprivate func makePdfRequest(_ projectID: Project.ID) async throws -> PdfClient.Request {
// @Dependency(\.manualD) var manualD
//
// guard let project = try await projects.get(projectID) else {
// throw ProjectClientError("Project not found. id: \(projectID)")
// }
// let frictionRateResponse = try await manualD.frictionRate(projectID: projectID)
// guard let frictionRate = frictionRateResponse.frictionRate else {
// throw ProjectClientError("Friction rate not found. id: \(projectID)")
// }
// let (ductSizes, sharedInfo, rooms) = try await calculateDuctSizes(projectID: projectID)
//
// return .init(
// project: project,
// rooms: rooms,
// componentLosses: frictionRateResponse.componentLosses,
// ductSizes: ductSizes,
// equipmentInfo: sharedInfo.equipmentInfo,
// maxSupplyTEL: sharedInfo.maxSupplyLength,
// maxReturnTEL: sharedInfo.maxReturnLenght,
// frictionRate: frictionRate,
// projectSHR: sharedInfo.projectSHR
// )
// }
fileprivate func makePdfRequest(_ projectID: Project.ID) async throws -> PdfClient.Request {
@Dependency(\.manualD) var manualD
guard let projectDetails = try await projects.detail(projectID) else {
throw ProjectClientError("Project not found. id: \(projectID)")
}
let (ductSizes, shared) = try await calculateDuctSizes(details: projectDetails)
let frictionRateResponse = try await manualD.frictionRate(details: projectDetails)
guard let frictionRate = frictionRateResponse.frictionRate else {
throw ProjectClientError("Friction rate not found. id: \(projectID)")
}
return .init(
details: projectDetails,
ductSizes: ductSizes,
shared: shared,
frictionRate: frictionRate
)
}
}
extension PdfClient.Request {
init(
details: Project.Detail,
ductSizes: DuctSizes,
shared: DuctSizeSharedRequest,
frictionRate: FrictionRate
) {
self.init(
project: details.project,
rooms: details.rooms,
componentLosses: details.componentLosses,
ductSizes: ductSizes,
equipmentInfo: details.equipmentInfo,
maxSupplyTEL: shared.maxSupplyLength,
maxReturnTEL: shared.maxReturnLenght,
frictionRate: frictionRate,
projectSHR: shared.projectSHR
)
}
}

View File

@@ -6,7 +6,7 @@ public struct DateView: HTML, Sendable {
var formatter: DateFormatter {
let formatter = DateFormatter()
formatter.dateStyle = .short
formatter.dateFormat = "MM/dd/yyyy"
return formatter
}

View File

@@ -1,16 +1,19 @@
import Elementary
import Foundation
import ManualDCore
public struct Number: HTML, Sendable {
let fractionDigits: Int
let value: Double
private var formatter: NumberFormatter {
let formatter = NumberFormatter()
formatter.maximumFractionDigits = fractionDigits
formatter.numberStyle = .decimal
return formatter
}
// private var formatter: NumberFormatter {
// let formatter = NumberFormatter()
// formatter.maximumFractionDigits = fractionDigits
// formatter.numberStyle = .decimal
// formatter.groupingSize = 3
// formatter.groupingSeparator = ","
// return formatter
// }
public init(
_ value: Double,
@@ -27,6 +30,6 @@ public struct Number: HTML, Sendable {
}
public var body: some HTML<HTMLTag.span> {
span { formatter.string(for: value) ?? "N/A" }
span { value.string(digits: fractionDigits) }
}
}

View File

@@ -1,89 +1,76 @@
import Elementary
import Foundation
public struct ResultView<
V: Sendable,
E: Error,
ValueView: HTML,
ErrorView: HTML
>: HTML {
public struct ResultView<ValueView, ErrorView>: HTML where ValueView: HTML, ErrorView: HTML {
let onSuccess: @Sendable (V) -> ValueView
let onError: @Sendable (E) -> ErrorView
let result: Result<V, E>
let result: Result<ValueView, any Error>
let errorView: @Sendable (any Error) -> ErrorView
public init(
result: Result<V, E>,
@HTMLBuilder onSuccess: @escaping @Sendable (V) -> ValueView,
@HTMLBuilder onError: @escaping @Sendable (E) -> ErrorView
) {
self.result = result
self.onError = onError
self.onSuccess = onSuccess
_ content: @escaping @Sendable () async throws -> ValueView,
onError: @escaping @Sendable (any Error) -> ErrorView
) async {
self.result = await Result(catching: content)
self.errorView = onError
}
public var body: some HTML {
switch result {
case .success(let value):
onSuccess(value)
case .success(let view):
view
case .failure(let error):
onError(error)
errorView(error)
}
}
}
extension ResultView {
extension ResultView where ErrorView == Styleguide.ErrorView {
public init(
result: Result<V, E>,
@HTMLBuilder onSuccess: @escaping @Sendable (V) -> ValueView
) where ErrorView == Styleguide.ErrorView<E> {
self.init(result: result, onSuccess: onSuccess) { error in
Styleguide.ErrorView(error: error)
}
_ content: @escaping @Sendable () async throws -> ValueView
) async {
await self.init(
content,
onError: { Styleguide.ErrorView(error: $0) }
)
}
public init<V: Sendable>(
catching: @escaping @Sendable () async throws -> V,
onSuccess content: @escaping @Sendable (V) -> ValueView
) async where ValueView: Sendable {
await self.init(
{
try await content(catching())
}
)
}
public init(
catching: @escaping @Sendable () async throws(E) -> V,
@HTMLBuilder onSuccess: @escaping @Sendable (V) -> ValueView
) async where ErrorView == Styleguide.ErrorView<E> {
catching: @escaping @Sendable () async throws -> Void
) async where ValueView == EmptyHTML {
await self.init(
result: .init(catching: catching),
onSuccess: onSuccess
) { error in
Styleguide.ErrorView(error: error)
}
}
public init(
catching: @escaping @Sendable () async throws(E) -> V,
) async where ErrorView == Styleguide.ErrorView<E>, V == Void, ValueView == EmptyHTML {
await self.init(
result: .init(catching: catching),
catching: catching,
onSuccess: { EmptyHTML() }
) { error in
Styleguide.ErrorView(error: error)
}
)
}
}
extension ResultView: Sendable where Error: Sendable, ValueView: Sendable, ErrorView: Sendable {}
extension ResultView: Sendable where ValueView: Sendable, ErrorView: Sendable {}
public struct ErrorView<E: Error>: HTML, Sendable where Error: Sendable {
public struct ErrorView: HTML, Sendable {
let error: any Error
let error: E
public init(error: E) {
public init(error: any Error) {
self.error = error
}
public var body: some HTML<HTMLTag.div> {
div {
h1(.class("text-2xl font-bold text-error")) { "Oops: Error" }
h1(.class("text-xl font-bold text-error")) { "Oops: Error" }
p {
"\(error)"
}
}
}
}

View File

@@ -1,78 +0,0 @@
import DatabaseClient
import Dependencies
import Fluent
import ManualDClient
import ManualDCore
import Vapor
// FIX: Remove these, not used currently.
extension DatabaseClient.Projects {
func fetchPage(
userID: User.ID,
page: Int = 1,
limit: Int = 25
) async throws -> Page<Project> {
try await fetch(userID, .init(page: page, per: limit))
}
func fetchPage(
userID: User.ID,
page: PageRequest
) async throws -> Page<Project> {
try await fetch(userID, page)
}
}
extension DatabaseClient {
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)
)
}
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.total
let designFrictionRate = (availableStaticPressure * 100) / tel
return (equipmentInfo, equivalentLengths, designFrictionRate)
}
}
extension DatabaseClient.ComponentLoss {
func createDefaults(projectID: Project.ID) async throws {
let defaults = ComponentPressureLoss.Create.default(projectID: projectID)
for loss in defaults {
_ = try await create(loss)
}
}
}
extension PageRequest {
static func next<T>(_ currentPage: Page<T>) -> Self {
.init(page: currentPage.metadata.page + 1, per: currentPage.metadata.per)
}
}

View File

@@ -1,38 +0,0 @@
import Logging
import ManualDClient
import ManualDCore
extension ManualDClient {
func calculate(
rooms: [Room],
trunks: [DuctSizing.TrunkSize],
designFrictionRateResult: (EquipmentInfo, EffectiveLength.MaxContainer, Double)?,
projectSHR: Double?,
logger: Logger? = nil
) 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 ([], []) }
let ductRooms = try await self.calculateSizes(
rooms: rooms,
trunks: trunks,
equipmentInfo: equipmentInfo,
maxSupplyLength: maxSupply,
maxReturnLength: maxReturn,
designFrictionRate: designFrictionRate,
projectSHR: projectSHR ?? 1.0,
logger: logger
)
// logger?.debug("Rooms: \(ductRooms)")
return ductRooms
}
}

View File

@@ -4,7 +4,7 @@ import ManualDCore
extension SiteRoute.View.ProjectRoute.DuctSizingRoute.TrunkSizeForm {
func toCreate(logger: Logger? = nil) throws -> DuctSizing.TrunkSize.Create {
func toCreate(logger: Logger? = nil) throws -> TrunkSize.Create {
try .init(
projectID: projectID,
type: type,
@@ -14,7 +14,7 @@ extension SiteRoute.View.ProjectRoute.DuctSizingRoute.TrunkSizeForm {
)
}
func toUpdate(logger: Logger? = nil) throws -> DuctSizing.TrunkSize.Update {
func toUpdate(logger: Logger? = nil) throws -> TrunkSize.Update {
try .init(
type: type,
rooms: makeRooms(logger: logger),

View File

@@ -1,3 +1,4 @@
import AuthClient
import Dependencies
import DependenciesMacros
import Elementary
@@ -15,10 +16,6 @@ public typealias AnySendableHTML = (any HTML & Sendable)
@DependencyClient
public struct ViewController: Sendable {
public typealias AuthenticateHandler = @Sendable (User) -> Void
public typealias CurrentUserHandler = @Sendable () throws -> User
public var view: @Sendable (Request) async throws -> AnySendableHTML
}
@@ -29,21 +26,15 @@ extension ViewController {
public let route: SiteRoute.View
public let isHtmxRequest: Bool
public let logger: Logger
public let authenticateUser: AuthenticateHandler
public let currentUser: CurrentUserHandler
public init(
route: SiteRoute.View,
isHtmxRequest: Bool,
logger: Logger,
authenticateUser: @escaping AuthenticateHandler,
currentUser: @escaping CurrentUserHandler
logger: Logger
) {
self.route = route
self.isHtmxRequest = isHtmxRequest
self.logger = logger
self.authenticateUser = authenticateUser
self.currentUser = currentUser
}
}
@@ -62,28 +53,23 @@ extension ViewController: DependencyKey {
extension ViewController.Request {
func currentUser() throws -> User {
@Dependency(\.authClient.currentUser) var currentUser
return try currentUser()
}
func authenticate(
_ login: User.Login
) async throws -> User {
@Dependency(\.database.users) var users
let token = try await users.login(login)
let user = try await users.get(token.userID)!
authenticateUser(user)
logger.debug("Logged in user: \(user.id)")
return user
@Dependency(\.authClient) var auth
return try await auth.login(login)
}
@discardableResult
func createAndAuthenticate(
_ signup: User.Create
) async throws -> User {
@Dependency(\.database.users) var users
let user = try await users.create(signup)
let _ = try await users.login(
.init(email: signup.email, password: signup.password)
)
authenticateUser(user)
logger.debug("Created and logged in user: \(user.id)")
return user
@Dependency(\.authClient) var auth
return try await auth.createAndLogin(signup)
}
}

View File

@@ -3,6 +3,8 @@ import Dependencies
import Elementary
import Foundation
import ManualDCore
import PdfClient
import ProjectClient
import Styleguide
extension ViewController.Request {
@@ -10,20 +12,27 @@ extension ViewController.Request {
func render() async -> AnySendableHTML {
@Dependency(\.database) var database
@Dependency(\.projectClient) var projectClient
@Dependency(\.pdfClient) var pdfClient
switch route {
case .test:
let projectID = UUID(uuidString: "A9C20153-E2E5-4C65-B33F-4D8A29C63A7A")!
return await view {
await ResultView {
return (
try await database.projects.getCompletedSteps(projectID),
try await database.calculateDuctSizes(projectID: projectID)
)
} onSuccess: { (_, result) in
TestPage(trunks: result.trunks, rooms: result.rooms)
}
}
// let projectID = UUID(uuidString: "E796C96C-F527-4753-A00A-EBCF25630663")!
// return await view {
// await ResultView {
//
// // return (
// // try await database.projects.getCompletedSteps(projectID),
// // try await projectClient.calculateDuctSizes(projectID)
// // )
// return try await pdfClient.html(.mock())
// } onSuccess: {
// $0
// // TestPage()
// // TestPage(trunks: result.trunks, rooms: result.rooms)
// }
// }
return try! await pdfClient.html(.mock())
case .login(let route):
switch route {
case .index(let next):
@@ -116,6 +125,7 @@ extension SiteRoute.View.ProjectRoute {
func renderView(on request: ViewController.Request) async -> AnySendableHTML {
@Dependency(\.database) var database
@Dependency(\.projectClient) var projectClient
switch self {
case .index:
@@ -124,7 +134,7 @@ extension SiteRoute.View.ProjectRoute {
let user = try request.currentUser()
return try await (
user.id,
database.projects.fetchPage(userID: user.id)
database.projects.fetch(user.id, .first)
)
} onSuccess: { (userID, projects) in
@@ -146,19 +156,14 @@ extension SiteRoute.View.ProjectRoute {
return await request.view {
await ResultView {
let user = try request.currentUser()
let project = try await database.projects.create(user.id, form)
try await database.componentLoss.createDefaults(projectID: project.id)
let rooms = try await database.rooms.fetch(project.id)
let shr = try await database.projects.getSensibleHeatRatio(project.id)
let completedSteps = try await database.projects.getCompletedSteps(project.id)
return (project.id, rooms, shr, completedSteps)
} onSuccess: { (projectID, rooms, shr, completedSteps) in
return try await projectClient.createProject(user.id, form)
} onSuccess: { response in
ProjectView(
projectID: projectID,
projectID: response.projectID,
activeTab: .rooms,
completedSteps: completedSteps
completedSteps: response.completedSteps
) {
RoomsView(rooms: rooms, sensibleHeatRatio: shr)
RoomsView(rooms: response.rooms, sensibleHeatRatio: response.sensibleHeatRatio)
}
}
}
@@ -187,6 +192,16 @@ extension SiteRoute.View.ProjectRoute {
return await route.renderView(on: request, projectID: projectID)
case .frictionRate(let route):
return await route.renderView(on: request, projectID: projectID)
case .pdf:
// return await ResultView2 {
// try await projectClient.toHTML(projectID)
// } onError: {
// ErrorView2(error: $0)
// }
// return await ResultView {
return try! await projectClient.toHTML(projectID)
// }
// fatalError()
case .rooms(let route):
return await route.renderView(on: request, projectID: projectID)
}
@@ -372,7 +387,7 @@ extension SiteRoute.View.ProjectRoute.FrictionRateRoute {
FrictionRateView(
componentLosses: losses,
equivalentLengths: lengths,
frictionRateResponse: frictionRate
frictionRate: frictionRate
)
}
@@ -416,32 +431,21 @@ extension SiteRoute.View.ProjectRoute.ComponentLossRoute {
) async -> AnySendableHTML {
@Dependency(\.database) var database
@Dependency(\.manualD) var manualD
@Dependency(\.projectClient) var projectClient
return await request.view {
await ResultView {
try await catching()
let equipment = try await database.equipment.fetch(projectID)
let componentLosses = try await database.componentLoss.fetch(projectID)
let lengths = try await database.effectiveLength.fetchMax(projectID)
return (
try await database.projects.getCompletedSteps(projectID),
componentLosses,
lengths,
try await manualD.frictionRate(
equipmentInfo: equipment,
componentLosses: componentLosses,
effectiveLength: lengths
)
try await projectClient.frictionRate(projectID)
)
} onSuccess: { (steps, losses, lengths, frictionRate) in
} onSuccess: { (steps, response) in
ProjectView(projectID: projectID, activeTab: .frictionRate, completedSteps: steps) {
FrictionRateView(
componentLosses: losses,
equivalentLengths: lengths,
frictionRateResponse: frictionRate
componentLosses: response.componentLosses,
equivalentLengths: response.equivalentLengths,
frictionRate: response.frictionRate
)
}
@@ -555,6 +559,7 @@ extension SiteRoute.View.ProjectRoute.DuctSizingRoute {
) async -> AnySendableHTML {
@Dependency(\.database) var database
@Dependency(\.manualD) var manualD
@Dependency(\.projectClient) var projectClient
switch self {
case .index:
@@ -563,8 +568,7 @@ extension SiteRoute.View.ProjectRoute.DuctSizingRoute {
case .deleteRectangularSize(let roomID, let request):
return await ResultView {
let room = try await database.rooms.deleteRectangularSize(roomID, request.rectangularSizeID)
return try await database.calculateDuctSizes(projectID: projectID)
.rooms
return try await projectClient.calculateRoomDuctSizes(projectID)
.filter({ $0.roomID == room.id && $0.roomRegister == request.register })
.first!
} onSuccess: { room in
@@ -577,8 +581,7 @@ extension SiteRoute.View.ProjectRoute.DuctSizingRoute {
roomID,
.init(id: form.id ?? .init(), register: form.register, height: form.height)
)
return try await database.calculateDuctSizes(projectID: projectID)
.rooms
return try await projectClient.calculateRoomDuctSizes(projectID)
.filter({ $0.roomID == room.id && $0.roomRegister == form.register })
.first!
} onSuccess: { room in
@@ -612,17 +615,18 @@ extension SiteRoute.View.ProjectRoute.DuctSizingRoute {
catching: @escaping @Sendable () async throws -> Void = {}
) async -> AnySendableHTML {
@Dependency(\.database) var database
@Dependency(\.projectClient) var project
return await request.view {
await ResultView {
try await catching()
return (
try await database.projects.getCompletedSteps(projectID),
try await database.calculateDuctSizes(projectID: projectID)
try await project.calculateDuctSizes(projectID)
)
} onSuccess: { (steps, ducts) in
ProjectView(projectID: projectID, activeTab: .ductSizing, completedSteps: steps) {
DuctSizingView(rooms: ducts.rooms, trunks: ducts.trunks)
DuctSizingView(ductSizes: ducts)
}
}
}

View File

@@ -7,8 +7,7 @@ struct DuctSizingView: HTML, Sendable {
@Environment(ProjectViewValue.$projectID) var projectID
let rooms: [DuctSizing.RoomContainer]
let trunks: [DuctSizing.TrunkContainer]
let ductSizes: DuctSizes
var body: some HTML {
div(.class("space-y-4")) {
@@ -21,13 +20,21 @@ struct DuctSizingView: HTML, Sendable {
Must complete all the previous sections to display duct sizing calculations.
"""
)
.hidden(when: rooms.count > 0)
.hidden(when: ductSizes.rooms.count > 0)
.attributes(.class("text-error font-bold italic mt-4"))
}
a(
.class("btn btn-primary"),
.href(route: .project(.detail(projectID, .pdf)))
) {
"PDF"
}
}
if rooms.count != 0 {
RoomsTable(rooms: rooms)
if ductSizes.rooms.count != 0 {
RoomsTable(rooms: ductSizes.rooms)
PageTitleRow {
PageTitle {
@@ -42,13 +49,13 @@ struct DuctSizingView: HTML, Sendable {
.tooltip("Add trunk / runout")
}
if trunks.count > 0 {
TrunkTable(trunks: trunks, rooms: rooms)
if ductSizes.trunks.count > 0 {
TrunkTable(ductSizes: ductSizes)
}
}
TrunkSizeForm(rooms: rooms, dismiss: true)
TrunkSizeForm(rooms: ductSizes.rooms, dismiss: true)
}
}

View File

@@ -5,7 +5,7 @@ import Styleguide
struct RectangularSizeForm: HTML, Sendable {
static func id(_ room: DuctSizing.RoomContainer) -> String {
static func id(_ room: DuctSizes.RoomContainer) -> String {
let base = "rectangularSize"
return "\(base)_\(room.roomName.idString)"
}
@@ -13,12 +13,12 @@ struct RectangularSizeForm: HTML, Sendable {
@Environment(ProjectViewValue.$projectID) var projectID
let id: String
let room: DuctSizing.RoomContainer
let room: DuctSizes.RoomContainer
let dismiss: Bool
init(
id: String? = nil,
room: DuctSizing.RoomContainer,
room: DuctSizes.RoomContainer,
dismiss: Bool = true
) {
self.id = Self.id(room)
@@ -40,7 +40,7 @@ struct RectangularSizeForm: HTML, Sendable {
}
var height: Int? {
room.rectangularSize?.height
room.ductSize.height
}
var body: some HTML<HTMLTag.dialog> {
@@ -54,7 +54,7 @@ struct RectangularSizeForm: HTML, Sendable {
.hx.swap(.outerHTML)
) {
input(.class("hidden"), .name("register"), .value(room.roomRegister))
input(.class("hidden"), .name("id"), .value(room.rectangularSize?.id))
input(.class("hidden"), .name("id"), .value(room.ductSize.rectangularID))
LabeledInput(
"Height",

View File

@@ -9,7 +9,7 @@ extension DuctSizingView {
struct RoomsTable: HTML, Sendable {
@Environment(ProjectViewValue.$projectID) var projectID
let rooms: [DuctSizing.RoomContainer]
let rooms: [DuctSizes.RoomContainer]
var body: some HTML<HTMLTag.table> {
@@ -34,17 +34,17 @@ extension DuctSizingView {
struct RoomRow: HTML, Sendable {
static func id(_ room: DuctSizing.RoomContainer) -> String {
static func id(_ room: DuctSizes.RoomContainer) -> String {
"roomRow_\(room.roomName.idString)"
}
@Environment(ProjectViewValue.$projectID) var projectID
let room: DuctSizing.RoomContainer
let room: DuctSizes.RoomContainer
let formID = UUID().idString
var deleteRoute: String {
guard let id = room.rectangularSize?.id else { return "" }
guard let id = room.rectangularID else { return "" }
return SiteRoute.View.router.path(
for: .project(
@@ -80,7 +80,7 @@ extension DuctSizingView {
span(.class("label")) { "Design" }
div(.class("flex justify-center")) {
Badge(number: room.designCFM.value, digits: 0)
Badge(number: room.ductSize.designCFM.value, digits: 0)
}
span(.class("label")) { "Heating" }
@@ -103,28 +103,28 @@ extension DuctSizingView {
div(.class("label")) { "Calculated" }
div(.class("flex justify-center")) {
Badge(number: room.roundSize, digits: 2)
Badge(number: room.ductSize.roundSize, digits: 2)
}
div {}
div(.class("label")) { "Final" }
div(.class("flex justify-center")) {
Badge(number: room.finalSize)
Badge(number: room.ductSize.finalSize)
.attributes(.class("badge-secondary"))
}
div {}
div(.class("label")) { "Flex" }
div(.class("flex justify-center")) {
Badge(number: room.flexSize)
Badge(number: room.ductSize.flexSize)
.attributes(.class("badge-primary"))
}
div {}
div(.class("label")) { "Rectangular" }
div(.class("flex justify-center")) {
if let width = room.rectangularWidth,
let height = room.rectangularSize?.height
if let width = room.ductSize.width,
let height = room.ductSize.height
{
Badge {
span { "\(width) x \(height)" }
@@ -134,7 +134,7 @@ extension DuctSizingView {
}
div(.class("flex justify-end")) {
div(.class("join")) {
if room.rectangularSize != nil {
if room.ductSize.width != nil {
Tooltip("Delete Size", position: .bottom) {
TrashButton()
.attributes(.class("join-item btn-ghost"))
@@ -142,7 +142,7 @@ extension DuctSizingView {
.hx.delete(deleteRoute),
.hx.target("#\(rowID)"),
.hx.swap(.outerHTML),
when: room.rectangularSize != nil
when: room.ductSize.width != nil
)
}
}

View File

@@ -5,7 +5,7 @@ import Styleguide
struct TrunkSizeForm: HTML, Sendable {
static func id(_ trunk: DuctSizing.TrunkContainer? = nil) -> String {
static func id(_ trunk: DuctSizes.TrunkContainer? = nil) -> String {
let base = "trunkSizeForm"
guard let trunk else { return base }
return "\(base)_\(trunk.id.idString)"
@@ -13,17 +13,17 @@ struct TrunkSizeForm: HTML, Sendable {
@Environment(ProjectViewValue.$projectID) var projectID
let container: DuctSizing.TrunkContainer?
let rooms: [DuctSizing.RoomContainer]
let container: DuctSizes.TrunkContainer?
let rooms: [DuctSizes.RoomContainer]
let dismiss: Bool
var trunk: DuctSizing.TrunkSize? {
var trunk: TrunkSize? {
container?.trunk
}
init(
trunk: DuctSizing.TrunkContainer? = nil,
rooms: [DuctSizing.RoomContainer],
trunk: DuctSizes.TrunkContainer? = nil,
rooms: [DuctSizes.RoomContainer],
dismiss: Bool = true
) {
self.container = trunk
@@ -56,7 +56,7 @@ struct TrunkSizeForm: HTML, Sendable {
label(.class("select w-full")) {
span(.class("label")) { "Type" }
select(.name("type")) {
for type in DuctSizing.TrunkSize.TrunkType.allCases {
for type in TrunkSize.TrunkType.allCases {
option(.value(type.rawValue)) { type.rawValue.capitalized }
.attributes(.selected, when: trunk?.type == type)
}
@@ -121,8 +121,8 @@ struct TrunkSizeForm: HTML, Sendable {
}
extension Array where Element == DuctSizing.TrunkSize.RoomProxy {
func hasRoom(_ room: DuctSizing.RoomContainer) -> Bool {
extension Array where Element == TrunkSize.RoomProxy {
func hasRoom(_ room: DuctSizes.RoomContainer) -> Bool {
first {
$0.id == room.roomID
&& $0.registers.contains(room.roomRegister)

View File

@@ -7,11 +7,10 @@ extension DuctSizingView {
struct TrunkTable: HTML, Sendable {
let trunks: [DuctSizing.TrunkContainer]
let rooms: [DuctSizing.RoomContainer]
let ductSizes: DuctSizes
private var sortedTrunks: [DuctSizing.TrunkContainer] {
trunks
private var sortedTrunks: [DuctSizes.TrunkContainer] {
ductSizes.trunks
.sorted(by: { $0.designCFM.value > $1.designCFM.value })
.sorted(by: { $0.type.rawValue > $1.type.rawValue })
}
@@ -29,7 +28,7 @@ extension DuctSizingView {
}
tbody {
for trunk in sortedTrunks {
TrunkRow(trunk: trunk, rooms: rooms)
TrunkRow(trunk: trunk, rooms: ductSizes.rooms)
}
}
}
@@ -41,8 +40,8 @@ extension DuctSizingView {
@Environment(ProjectViewValue.$projectID) var projectID
let trunk: DuctSizing.TrunkContainer
let rooms: [DuctSizing.RoomContainer]
let trunk: DuctSizes.TrunkContainer
let rooms: [DuctSizes.RoomContainer]
var body: some HTML<HTMLTag.tr> {
tr {
@@ -135,17 +134,18 @@ extension DuctSizingView {
}
private var registerIDS: [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.roomName)
}
}
}
.sorted()
trunk.registerIDS(rooms: rooms)
// 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.roomName)
// }
// }
// }
// .sorted()
}
}

View File

@@ -9,37 +9,33 @@ struct FrictionRateView: HTML, Sendable {
let componentLosses: [ComponentPressureLoss]
let equivalentLengths: EffectiveLength.MaxContainer
let frictionRateResponse: ManualDClient.FrictionRateResponse?
let frictionRate: FrictionRate?
private var availableStaticPressure: Double? {
frictionRateResponse?.availableStaticPressure
}
private var frictionRateDesignValue: Double? {
frictionRateResponse?.frictionRate
frictionRate?.availableStaticPressure
}
private var shouldShowBadges: Bool {
frictionRateDesignValue != nil || availableStaticPressure != nil
frictionRate != nil
}
private var badgeColor: String {
let base = "badge-info"
guard let frictionRateDesignValue else { return base }
if frictionRateDesignValue >= 0.18 || frictionRateDesignValue <= 0.02 {
guard let frictionRate = frictionRate?.value else { return base }
if frictionRate >= 0.18 || frictionRate <= 0.02 {
return "badge-error"
}
return base
}
private var showHighErrors: Bool {
guard let frictionRateDesignValue else { return false }
return frictionRateDesignValue >= 0.18
guard let frictionRate = frictionRate?.value else { return false }
return frictionRate >= 0.18
}
private var showLowErrors: Bool {
guard let frictionRateDesignValue else { return false }
return frictionRateDesignValue <= 0.02
guard let frictionRate = frictionRate?.value else { return false }
return frictionRate <= 0.02
}
private var showNoComponentLossesError: Bool {
@@ -47,7 +43,7 @@ struct FrictionRateView: HTML, Sendable {
}
private var showIncompleteSectionsError: Bool {
availableStaticPressure == nil || frictionRateDesignValue == nil
availableStaticPressure == nil || frictionRate?.value == nil
}
private var hasAlerts: Bool {
@@ -68,11 +64,11 @@ struct FrictionRateView: HTML, Sendable {
div(.class("space-y-2 justify-end font-bold text-lg")) {
if shouldShowBadges {
if let frictionRateDesignValue {
if let frictionRate = frictionRate?.value {
LabeledContent {
span { "Friction Rate Design Value" }
} content: {
Badge(number: frictionRateDesignValue, digits: 2)
Badge(number: frictionRate, digits: 2)
.attributes(.class("\(badgeColor) badge-lg"))
.bold()
}

View File

@@ -234,7 +234,7 @@ extension ManualDClient {
equipmentInfo: EquipmentInfo?,
componentLosses: [ComponentPressureLoss],
effectiveLength: EffectiveLength.MaxContainer
) async throws -> FrictionRateResponse? {
) async throws -> FrictionRate? {
guard let staticPressure = equipmentInfo?.staticPressure else {
return nil
}

View File

@@ -21,7 +21,7 @@ struct RoomsView: HTML, Sendable {
}
div(.class("flex justify-end grow")) {
Tooltip("Project wide sensible heat ratio", position: .left) {
Tooltip("Set sensible heat ratio", position: .left) {
button(
.class(
"""
@@ -43,23 +43,24 @@ struct RoomsView: HTML, Sendable {
}
.attributes(.class("border border-error"), when: sensibleHeatRatio == nil)
}
.attributes(.class("tooltip-open"), when: sensibleHeatRatio == nil)
}
div(.class("flex items-end space-x-4 font-bold")) {
span(.class("text-lg")) { "Heating Total" }
Badge(number: rooms.heatingTotal, digits: 0)
Badge(number: rooms.totalHeatingLoad, digits: 0)
.attributes(.class("badge-error"))
}
div(.class("flex justify-center items-end space-x-4 my-auto font-bold")) {
span(.class("text-lg")) { "Cooling Total" }
Badge(number: rooms.coolingTotal, digits: 0)
Badge(number: rooms.totalCoolingLoad, 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.coolingSensible(shr: sensibleHeatRatio), digits: 0)
Badge(number: rooms.totalCoolingSensible(shr: sensibleHeatRatio ?? 1.0), digits: 0)
.attributes(.class("badge-info"))
}
}
@@ -67,7 +68,7 @@ struct RoomsView: HTML, Sendable {
SHRForm(
sensibleHeatRatio: sensibleHeatRatio,
dismiss: sensibleHeatRatio != nil
dismiss: true
)
table(.class("table table-zebra text-lg"), .id("roomsTable")) {
@@ -237,22 +238,3 @@ struct RoomsView: HTML, Sendable {
}
}
}
extension Array where Element == Room {
var heatingTotal: Double {
reduce(into: 0) { $0 += $1.heatingLoad }
}
var coolingTotal: Double {
reduce(into: 0) { $0 += $1.coolingTotal }
}
func coolingSensible(shr: Double?) -> Double {
let shr = shr ?? 1.0
return reduce(into: 0) {
let sensible = $1.coolingSensible ?? ($1.coolingTotal * shr)
$0 += sensible
}
}
}

View File

@@ -5,27 +5,27 @@ import ManualDCore
import Styleguide
struct TestPage: HTML, Sendable {
let trunks: [DuctSizing.TrunkContainer]
let rooms: [DuctSizing.RoomContainer]
// let ductSizes: DuctSizes
var body: some HTML {
div(.class("overflow-auto")) {
DuctSizingView.TrunkTable(trunks: trunks, 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")) {}
DuctSizingView.TrunkTable(trunks: trunks, rooms: rooms)
}
div {}
// div(.class("overflow-auto")) {
// DuctSizingView.TrunkTable(ductSizes: ductSizes)
//
// 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")) {}
//
// DuctSizingView.TrunkTable(ductSizes: ductSizes)
// }
}
}

9
TODO.md Normal file
View File

@@ -0,0 +1,9 @@
# TODO's
- [x] Fix theme not working when selected upon signup.
- [ ] Pdf generation
- [ ] Add postgres / mysql support
- [ ] Opensource / license ??
- [ ] Figure out domain to host (currently thinking ductcalc.pro)
- [ ] Logo / navbar name may have to change if it's not duct-calc.
- [ ] MainPage meta items will have to change also

View File

@@ -27,7 +27,7 @@ struct ManualDClientTests {
let response = try await manualD.ductSize(
.init(designCFM: 88, frictionRate: 0.06)
)
#expect(numberFormatter.string(for: response.ductulatorSize) == "6.07")
#expect(numberFormatter.string(for: response.calculatedSize) == "6.07")
#expect(response.finalSize == 7)
#expect(response.flexSize == 7)
#expect(response.velocity == 329)
@@ -61,7 +61,7 @@ struct ManualDClientTests {
@Test
func totalEffectiveLength() async throws {
let response = try await manualD.totalEffectiveLength(
let response = try await manualD.totalEquivalentLength(
.init(
trunkLengths: [25],
runoutLengths: [10],
@@ -79,7 +79,7 @@ struct ManualDClientTests {
@Test
func equivalentRectangularDuct() async throws {
let response = try await manualD.equivalentRectangularDuct(.init(round: 7, height: 8))
let response = try await manualD.rectangularSize(.init(round: 7, height: 8))
#expect(response.height == 8)
#expect(response.width == 5)
}

View File

@@ -17,6 +17,7 @@ RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \
npm \
build-essential \
curl \
wkhtmltopdf \
&& rm -r /var/lib/apt/lists/*
# Set up a build area

359
ductsizes.json Normal file
View File

@@ -0,0 +1,359 @@
{
"trunks": [
{
"trunk": {
"projectID": "E796C96C-F527-4753-A00A-EBCF25630663",
"type": "supply",
"name": "West",
"id": "AFBC5264-8129-4383-97D8-F44CF5258A53",
"rooms": [
{
"room": {
"coolingTotal": 4567,
"id": "FA2F107F-5DF0-4D69-B231-DE3C6EF64134",
"registerCount": 3,
"heatingLoad": 9876,
"updatedAt": "2026-01-17T16:51:33Z",
"createdAt": "2026-01-17T16:51:33Z",
"projectID": "E796C96C-F527-4753-A00A-EBCF25630663",
"name": "Kitchen"
},
"registers": [1, 2, 3]
},
{
"room": {
"coolingTotal": 4567,
"id": "9A01CEE8-6A4B-4299-9353-58AFAD042903",
"registerCount": 3,
"heatingLoad": 9876,
"updatedAt": "2026-01-17T16:51:21Z",
"createdAt": "2026-01-17T16:51:21Z",
"name": "Master",
"projectID": "E796C96C-F527-4753-A00A-EBCF25630663"
},
"registers": [1, 2, 3]
}
],
"height": 8
},
"ductSize": {
"height": 8,
"roundSize": 12.3660960368007,
"width": 19,
"velocity": 737,
"finalSize": 14,
"flexSize": 14,
"designCFM": {
"cooling": {
"_0": 787.278055507671
}
}
}
},
{
"trunk": {
"projectID": "E796C96C-F527-4753-A00A-EBCF25630663",
"name": "East",
"id": "C6A21594-0A28-4A20-BC15-628502010201",
"type": "supply",
"height": 8,
"rooms": [
{
"room": {
"coolingTotal": 1234,
"id": "2420F9C7-0FCA-4E92-BAC9-8A054CB3201B",
"registerCount": 1,
"heatingLoad": 4567,
"updatedAt": "2026-01-17T16:51:10Z",
"createdAt": "2026-01-17T16:51:10Z",
"name": "Entry",
"projectID": "E796C96C-F527-4753-A00A-EBCF25630663"
},
"registers": [1]
},
{
"room": {
"coolingTotal": 1234,
"id": "449FE324-2ACF-4C12-83A3-EB86FD45A991",
"registerCount": 2,
"heatingLoad": 4567,
"updatedAt": "2026-01-17T16:51:02Z",
"createdAt": "2026-01-17T16:51:02Z",
"projectID": "E796C96C-F527-4753-A00A-EBCF25630663",
"name": "Bed-1"
},
"registers": [1, 2]
}
]
},
"ductSize": {
"height": 8,
"roundSize": 8.39504804369037,
"width": 8,
"velocity": 643,
"flexSize": 10,
"finalSize": 9,
"designCFM": {
"heating": {
"_0": 284.587689538185
}
}
}
},
{
"trunk": {
"name": "Main",
"projectID": "E796C96C-F527-4753-A00A-EBCF25630663",
"id": "C65863A0-D2EE-4D90-8C71-B1FD2454A8DF",
"type": "return",
"rooms": [
{
"registers": [1, 2, 3],
"room": {
"coolingTotal": 4567,
"id": "FA2F107F-5DF0-4D69-B231-DE3C6EF64134",
"registerCount": 3,
"heatingLoad": 9876,
"updatedAt": "2026-01-17T16:51:33Z",
"createdAt": "2026-01-17T16:51:33Z",
"name": "Kitchen",
"projectID": "E796C96C-F527-4753-A00A-EBCF25630663"
}
},
{
"registers": [1, 2, 3],
"room": {
"coolingTotal": 4567,
"id": "9A01CEE8-6A4B-4299-9353-58AFAD042903",
"registerCount": 3,
"heatingLoad": 9876,
"updatedAt": "2026-01-17T16:51:21Z",
"createdAt": "2026-01-17T16:51:21Z",
"name": "Master",
"projectID": "E796C96C-F527-4753-A00A-EBCF25630663"
}
},
{
"registers": [1],
"room": {
"coolingTotal": 1234,
"id": "2420F9C7-0FCA-4E92-BAC9-8A054CB3201B",
"registerCount": 1,
"heatingLoad": 4567,
"updatedAt": "2026-01-17T16:51:10Z",
"createdAt": "2026-01-17T16:51:10Z",
"name": "Entry",
"projectID": "E796C96C-F527-4753-A00A-EBCF25630663"
}
},
{
"registers": [1, 2],
"room": {
"coolingTotal": 1234,
"id": "449FE324-2ACF-4C12-83A3-EB86FD45A991",
"registerCount": 2,
"heatingLoad": 4567,
"updatedAt": "2026-01-17T16:51:02Z",
"createdAt": "2026-01-17T16:51:02Z",
"name": "Bed-1",
"projectID": "E796C96C-F527-4753-A00A-EBCF25630663"
}
}
]
},
"ductSize": {
"designCFM": {
"cooling": {
"_0": 1000
}
},
"roundSize": 13.539327773393,
"velocity": 935,
"finalSize": 14,
"flexSize": 16
}
}
],
"rooms": [
{
"ductSize": {
"velocity": 521,
"flexSize": 6,
"designCFM": {
"heating": {
"_0": 71.1469223845461
}
},
"roundSize": 4.95724506597333,
"finalSize": 5
},
"heatingCFM": 71.1469223845461,
"roomRegister": 1,
"heatingLoad": 2283.5,
"roomName": "Bed-1-1",
"roomID": "449FE324-2ACF-4C12-83A3-EB86FD45A991",
"coolingLoad": 512.11,
"coolingCFM": 53.1804861230822
},
{
"ductSize": {
"velocity": 521,
"flexSize": 6,
"designCFM": {
"heating": {
"_0": 71.1469223845461
}
},
"roundSize": 4.95724506597333,
"finalSize": 5
},
"heatingCFM": 71.1469223845461,
"roomRegister": 2,
"heatingLoad": 2283.5,
"roomName": "Bed-1-2",
"roomID": "449FE324-2ACF-4C12-83A3-EB86FD45A991",
"coolingLoad": 512.11,
"coolingCFM": 53.1804861230822
},
{
"ductSize": {
"velocity": 532,
"flexSize": 7,
"designCFM": {
"heating": {
"_0": 142.293844769092
}
},
"roundSize": 6.4510704920341,
"finalSize": 7
},
"heatingCFM": 142.293844769092,
"roomRegister": 1,
"heatingLoad": 4567,
"roomName": "Entry-1",
"roomID": "2420F9C7-0FCA-4E92-BAC9-8A054CB3201B",
"coolingLoad": 1024.22,
"coolingCFM": 106.360972246164
},
{
"ductSize": {
"velocity": 490,
"flexSize": 7,
"designCFM": {
"cooling": {
"_0": 131.213009251279
}
},
"roundSize": 6.25641154872314,
"finalSize": 7
},
"heatingCFM": 102.568718410303,
"roomRegister": 1,
"heatingLoad": 3292,
"roomName": "Kitchen-1",
"roomID": "FA2F107F-5DF0-4D69-B231-DE3C6EF64134",
"coolingLoad": 1263.53666666667,
"coolingCFM": 131.213009251279
},
{
"ductSize": {
"velocity": 490,
"flexSize": 7,
"designCFM": {
"cooling": {
"_0": 131.213009251279
}
},
"roundSize": 6.25641154872314,
"finalSize": 7
},
"heatingCFM": 102.568718410303,
"roomRegister": 2,
"heatingLoad": 3292,
"roomName": "Kitchen-2",
"roomID": "FA2F107F-5DF0-4D69-B231-DE3C6EF64134",
"coolingLoad": 1263.53666666667,
"coolingCFM": 131.213009251279
},
{
"ductSize": {
"velocity": 490,
"flexSize": 7,
"designCFM": {
"cooling": {
"_0": 131.213009251279
}
},
"roundSize": 6.25641154872314,
"finalSize": 7
},
"heatingCFM": 102.568718410303,
"roomRegister": 3,
"heatingLoad": 3292,
"roomName": "Kitchen-3",
"roomID": "FA2F107F-5DF0-4D69-B231-DE3C6EF64134",
"coolingLoad": 1263.53666666667,
"coolingCFM": 131.213009251279
},
{
"ductSize": {
"velocity": 490,
"flexSize": 7,
"designCFM": {
"cooling": {
"_0": 131.213009251279
}
},
"roundSize": 6.25641154872314,
"finalSize": 7
},
"heatingCFM": 102.568718410303,
"roomRegister": 1,
"heatingLoad": 3292,
"roomName": "Master-1",
"roomID": "9A01CEE8-6A4B-4299-9353-58AFAD042903",
"coolingLoad": 1263.53666666667,
"coolingCFM": 131.213009251279
},
{
"ductSize": {
"velocity": 490,
"flexSize": 7,
"designCFM": {
"cooling": {
"_0": 131.213009251279
}
},
"roundSize": 6.25641154872314,
"finalSize": 7
},
"heatingCFM": 102.568718410303,
"roomRegister": 2,
"heatingLoad": 3292,
"roomName": "Master-2",
"roomID": "9A01CEE8-6A4B-4299-9353-58AFAD042903",
"coolingLoad": 1263.53666666667,
"coolingCFM": 131.213009251279
},
{
"ductSize": {
"velocity": 490,
"flexSize": 7,
"designCFM": {
"cooling": {
"_0": 131.213009251279
}
},
"roundSize": 6.25641154872314,
"finalSize": 7
},
"heatingCFM": 102.568718410303,
"roomRegister": 3,
"heatingLoad": 3292,
"roomName": "Master-3",
"roomID": "9A01CEE8-6A4B-4299-9353-58AFAD042903",
"coolingLoad": 1263.53666666667,
"coolingCFM": 131.213009251279
}
}
]

View File

@@ -1,6 +1,9 @@
docker_image := "ductcalc"
docker_tag := "latest"
clean:
rm -rf .build
install-deps:
@curl -sL daisyui.com/fast | bash