Compare commits

...

13 Commits

43 changed files with 2711 additions and 169 deletions

View File

@@ -1,8 +1,14 @@
# NOTE: Builds currently fail when building in release mode.
ARG SWIFT_MODE="debug"
# ================================
# Build image
# ================================
FROM swift:6.0-noble AS build
ARG SWIFT_MODE
# Install OS updates
RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \
&& apt-get -q update \
@@ -17,30 +23,35 @@ WORKDIR /build
# as long as your Package.swift/Package.resolved
# files do not change.
COPY ./Package.* ./
RUN swift package resolve \
$([ -f ./Package.resolved ] && echo "--force-resolved-versions" || true)
RUN --mount=type=cache,target=/build/.build swift package resolve \
$([ -f ./Package.resolved ] && echo "--force-resolved-versions" || true)
# Copy entire repo into container
COPY . .
# Build the application, with optimizations, with static linking, and using jemalloc
# N.B.: The static version of jemalloc is incompatible with the static Swift runtime.
RUN swift build -c release \
--product App \
--static-swift-stdlib \
-Xlinker -ljemalloc
RUN --mount=type=cache,target=/build/.build swift build \
-c ${SWIFT_MODE} \
--product App \
--static-swift-stdlib \
-Xswiftc -g \
-Xlinker -ljemalloc
# Switch to the staging area
WORKDIR /staging
# Copy main executable to staging area
RUN cp "$(swift build --package-path /build -c release --show-bin-path)/App" ./
RUN --mount=type=cache,target=/build/.build cp \
"$(swift build --package-path /build -c ${SWIFT_MODE} --show-bin-path)/App" ./
# Copy static swift backtracer binary to staging area
RUN cp "/usr/libexec/swift/linux/swift-backtrace-static" ./
RUN --mount=type=cache,target=/build/.build \
cp "/usr/libexec/swift/linux/swift-backtrace-static" ./
# Copy resources bundled by SPM to staging area
RUN find -L "$(swift build --package-path /build -c release --show-bin-path)/" -regex '.*\.resources$' -exec cp -Ra {} ./ \;
RUN --mount=type=cache,target=/build/.build \
find -L "$(swift build --package-path /build -c ${SWIFT_MODE} --show-bin-path)/" -regex '.*\.resources$' -exec cp -Ra {} ./ \;
# Copy any resources from the public directory and views directory if the directories exist
# Ensure that by default, neither the directory nor any of its contents are writable.

100
Dockerfile.dev Normal file
View File

@@ -0,0 +1,100 @@
# NOTE: Builds currently fail when building in release mode.
ARG SWIFT_MODE="debug"
# ================================
# Build image
# ================================
FROM swift:6.0-noble AS build
ARG SWIFT_MODE
# Install OS updates
RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \
&& apt-get -q update \
&& apt-get -q dist-upgrade -y \
&& apt-get install -y libjemalloc-dev
# Set up a build area
WORKDIR /build
# First just resolve dependencies.
# This creates a cached layer that can be reused
# as long as your Package.swift/Package.resolved
# files do not change.
COPY ./Package.* ./
RUN --mount=type=cache,target=/build/.build swift package resolve \
$([ -f ./Package.resolved ] && echo "--force-resolved-versions" || true)
# Copy entire repo into container
COPY . .
# Build the application, with optimizations, with static linking, and using jemalloc
# N.B.: The static version of jemalloc is incompatible with the static Swift runtime.
RUN --mount=type=cache,target=/build/.build swift build \
-c ${SWIFT_MODE} \
--product App \
--static-swift-stdlib \
-Xswiftc -g \
-Xlinker -ljemalloc
# Switch to the staging area
WORKDIR /staging
# Copy main executable to staging area
RUN --mount=type=cache,target=/build/.build cp \
"$(swift build --package-path /build -c ${SWIFT_MODE} --show-bin-path)/App" ./
# Copy static swift backtracer binary to staging area
RUN --mount=type=cache,target=/build/.build \
cp "/usr/libexec/swift/linux/swift-backtrace-static" ./
# Copy resources bundled by SPM to staging area
RUN --mount=type=cache,target=/build/.build \
find -L "$(swift build --package-path /build -c ${SWIFT_MODE} --show-bin-path)/" -regex '.*\.resources$' -exec cp -Ra {} ./ \;
# Copy any resources from the public directory and views directory if the directories exist
# Ensure that by default, neither the directory nor any of its contents are writable.
RUN [ -d /build/Public ] && { mv /build/Public ./Public && chmod -R a-w ./Public; } || true
RUN [ -d /build/Resources ] && { mv /build/Resources ./Resources && chmod -R a-w ./Resources; } || true
# ================================
# Run image
# ================================
FROM ubuntu:noble
# Make sure all system packages are up to date, and install only essential packages.
RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \
&& apt-get -q update \
&& apt-get -q dist-upgrade -y \
&& apt-get -q install -y \
libjemalloc2 \
ca-certificates \
tzdata \
# If your app or its dependencies import FoundationNetworking, also install `libcurl4`.
libcurl4 \
curl \
# If your app or its dependencies import FoundationXML, also install `libxml2`.
# libxml2 \
&& rm -r /var/lib/apt/lists/*
# Create a vapor user and group with /app as its home directory
RUN useradd --user-group --create-home --system --skel /dev/null --home-dir /app vapor
# Switch to the new home directory
WORKDIR /app
# Copy built executable and any staged resources from builder
COPY --from=build --chown=vapor:vapor /staging /app
# Provide configuration needed by the built-in crash reporter and some sensible default behaviors.
ENV SWIFT_BACKTRACE=enable=yes,sanitize=yes,threads=all,images=all,interactive=no,swift-backtrace=./swift-backtrace-static
# Ensure all further commands run as the vapor user
USER vapor:vapor
# Let Docker bind to port 8080
EXPOSE 8080
# Start the Vapor service when the image is run, default to listening on 8080 in production environment
ENTRYPOINT ["./App"]
CMD ["serve", "--env", "production", "--hostname", "0.0.0.0", "--port", "8080"]

View File

@@ -9,6 +9,9 @@ let package = Package(
products: [
.executable(name: "App", targets: ["App"]),
.library(name: "ApiController", targets: ["ApiController"]),
.library(name: "ClimateZoneClient", targets: ["ClimateZoneClient"]),
.library(name: "CoreModels", targets: ["CoreModels"]),
.library(name: "LocationClient", targets: ["LocationClient"]),
.library(name: "Routes", targets: ["Routes"]),
.library(name: "Styleguide", targets: ["Styleguide"]),
.library(name: "ViewController", targets: ["ViewController"])
@@ -59,6 +62,27 @@ let package = Package(
],
swiftSettings: swiftSettings
),
.target(
name: "LocationClient",
dependencies: [
.product(name: "Dependencies", package: "swift-dependencies"),
.product(name: "DependenciesMacros", package: "swift-dependencies")
],
swiftSettings: swiftSettings
),
.target(
name: "ClimateZoneClient",
dependencies: [
.product(name: "Dependencies", package: "swift-dependencies"),
.product(name: "DependenciesMacros", package: "swift-dependencies")
],
swiftSettings: swiftSettings
),
.target(
name: "CoreModels",
dependencies: [],
swiftSettings: swiftSettings
),
.target(
name: "HTMLSnapshotTesting",
dependencies: [
@@ -67,9 +91,18 @@ let package = Package(
],
swiftSettings: swiftSettings
),
.target(
name: "LocationClient",
dependencies: [
.product(name: "Dependencies", package: "swift-dependencies"),
.product(name: "DependenciesMacros", package: "swift-dependencies")
],
swiftSettings: swiftSettings
),
.target(
name: "Routes",
dependencies: [
"CoreModels",
.product(name: "CasePaths", package: "swift-case-paths"),
.product(name: "Dependencies", package: "swift-dependencies"),
.product(name: "Elementary", package: "elementary"),
@@ -107,5 +140,6 @@ var swiftSettings: [SwiftSetting] { [
.enableExperimentalFeature("StrictConcurrency=complete"),
.enableUpcomingFeature("ExistentialAny"),
.enableUpcomingFeature("ConciseMagicFile"),
.enableUpcomingFeature("ForwardTrailingClosures")
.enableUpcomingFeature("ForwardTrailingClosures"),
.enableUpcomingFeature("InferSendableFromCaptures")
] }

File diff suppressed because one or more lines are too long

View File

@@ -25,11 +25,25 @@ extension ApiController: DependencyKey {
@Dependency(\.psychrometricClient) var psychrometricClient
return .init(json: { route, logger in
logger.debug("API Route: \(route)")
switch route {
case let .calculateAtticVentilation(request):
logger.debug("Calculating attic ventilation: \(request)")
return try await request.respond(logger: logger)
case let .calculateCapacitor(request):
logger.debug("Calculating capacitor: \(request)")
return try await request.respond(logger: logger)
case let .calculateDehumidifierSize(request):
logger.debug("Calculating dehumidifier size: \(request)")
return try await request.respond(logger)
case let .calculateFilterPressureDrop(request):
logger.debug("Calculating filter pressure drop: \(request)")
return try await request.respond(logger: logger)
case let .calculateHVACSystemPerformance(request):
logger.debug("Calculating hvac system performance: \(request)")
return try await request.respond(logger: logger)
@@ -37,6 +51,10 @@ extension ApiController: DependencyKey {
case let .calculateMoldRisk(request):
logger.debug("Calculating mold risk: \(request)")
return try await psychrometricClient.respond(request, logger)
case let .calculateRoomPressure(request):
logger.debug("Calculating room pressure: \(request)")
return try await request.respond(logger: logger)
}
})
}

View File

@@ -0,0 +1,165 @@
import Foundation
import Logging
import OrderedCollections
import Routes
public extension AtticVentilation.Request {
private static let stackEffectCoefficient = 0.0188
private static let windEffectCoefficient = 0.0299
private static let minimumVentRatio = 1.0 / 300.0
private static let recommendedVentRatio = 1.0 / 150
private static let intakeToExhaustRatio = 1.5
// swiftlint:disable function_body_length
func respond(logger: Logger) async throws -> AtticVentilation.Response {
try validate()
var recommendations = [String]()
var warnings = [String]()
var ventilationStatus: AtticVentilation.VentilationStatus = .adequate
let temperatureDifference = checkTemperatureDifferential(
ventilationStatus: &ventilationStatus,
warnings: &warnings
)
let dewpointDifference = atticDewpoint - outdoorDewpoint
let stackEffect = Self.stackEffectCoefficient * atticFloorArea * sqrt(abs(temperatureDifference))
* (temperatureDifference < 0 ? -1 : 1)
let minimumVentArea = atticFloorArea * Self.minimumVentRatio
let recommendVentArea = atticFloorArea * Self.recommendedVentRatio
let requiredIntake = (recommendVentArea * Self.intakeToExhaustRatio) / (1 + Self.intakeToExhaustRatio)
let requiredExhaust = recommendVentArea - requiredIntake
if abs(pressureDifferential) > 2 {
warnings.append(
"High pressure differential - may indicate blocked or inadequate ventilation"
)
if ventilationStatus != .critical {
ventilationStatus = .inadequate
}
}
if abs(dewpointDifference) > 5 {
warnings.append(
"High moisture levels in the attic - increased ventilation is recommended"
)
if ventilationStatus != .critical {
ventilationStatus = .inadequate
}
}
// compare existing ventilation to requirements.
checkExistingAreas(
minimumVentArea: minimumVentArea,
recommendVentArea: recommendVentArea,
requiredIntake: requiredIntake,
requiredExhaust: requiredExhaust,
recommendations: &recommendations,
ventilationStatus: &ventilationStatus,
warnings: &warnings
)
// Pressure recommendations
addPressureRecommendations(&recommendations)
return .init(
pressureDifferential: pressureDifferential,
temperatureDifferential: temperatureDifference,
dewpointDifferential: dewpointDifference,
stackEffect: stackEffect,
requiredVentilationArea: .init(intake: requiredIntake, exhaust: requiredExhaust),
recommendations: recommendations,
warnings: warnings,
ventilationStatus: ventilationStatus
)
}
// swiftlint:enable function_body_length
private func checkTemperatureDifferential(
ventilationStatus: inout AtticVentilation.VentilationStatus,
warnings: inout [String]
) -> Double {
let temperatureDifference = atticTemperature - outdoorTemperature
let absTempDiff = abs(temperatureDifference)
if absTempDiff > 20 {
warnings.append(
"High temperature differential - indicates insufficient ventilation."
)
ventilationStatus = .inadequate
}
if absTempDiff > 30 {
warnings.append(
"Critical temperature differential - immediate action recommended"
)
ventilationStatus = .critical
}
return temperatureDifference
}
// swiftlint:disable function_parameter_count
private func checkExistingAreas(
minimumVentArea: Double,
recommendVentArea: Double,
requiredIntake: Double,
requiredExhaust: Double,
recommendations: inout [String],
ventilationStatus: inout AtticVentilation.VentilationStatus,
warnings: inout [String]
) {
if let existingIntakeArea, let existingExhaustArea {
let totalExisting = existingIntakeArea + existingExhaustArea
if totalExisting < minimumVentArea {
warnings.append("Existing ventilation area below minimum requirement.")
if ventilationStatus != .critical {
ventilationStatus = .inadequate
}
} else if totalExisting < recommendVentArea {
recommendations.append("Consider increasing ventilation area to meet recommended guidelines.")
}
if existingIntakeArea < requiredIntake {
recommendations.append(
"Add \(double: requiredIntake - existingIntakeArea, fractionDigits: 1) ft² of intake ventilation."
)
}
if existingExhaustArea < requiredExhaust {
recommendations.append(
"Add \(double: requiredExhaust - existingExhaustArea, fractionDigits: 1) ft² of exhaust ventilation."
)
}
} else {
recommendations.append(
"Add \(double: requiredIntake, fractionDigits: 1) ft² of intake ventilation."
)
recommendations.append(
"Add \(double: requiredExhaust, fractionDigits: 1) ft² of exhaust ventilation."
)
}
}
// swiftlint:enable function_parameter_count
private func addPressureRecommendations(_ recommendations: inout [String]) {
if pressureDifferential > 0 {
recommendations.append("Consider adding more exhaust ventilation to balance pressure.")
} else if pressureDifferential < 0 {
recommendations.append("Consider adding more intake ventilation to balance pressure.")
}
}
private func validate() throws {
guard atticFloorArea > 0 else {
throw ValidationError(message: "Attic floor area should be greater than 0.")
}
if let existingIntakeArea, existingIntakeArea < 0 {
throw ValidationError(message: "Existing intake area should be greater than 0.")
}
if let existingExhaustArea, existingExhaustArea < 0 {
throw ValidationError(message: "Existing exhaust area should be greater than 0.")
}
}
}

View File

@@ -0,0 +1,117 @@
import Foundation
import Logging
import OrderedCollections
import Routes
public extension Capacitor.Request {
static let standardCapacitorSizes = OrderedSet([
5, 7.5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 70, 80
])
func respond(logger: Logger) async throws -> Capacitor.Response {
switch self {
case let .size(request):
return try calculateSize(request, logger)
case let .test(request):
return try calculateCapcitance(request, logger)
}
}
private func calculateCapcitance(
_ request: Capacitor.Request.TestRequest,
_ logger: Logger
) throws -> Capacitor.Response {
try request.validate()
let capacitance = (2653 * request.startWindingAmps) / request.runToCommonVoltage
logger.debug("Test Capacitor calculated capacitance: \(capacitance)")
var ratedComparison: Capacitor.RatedComparison?
if let rating = request.ratedCapacitorSize {
let rating = Double(rating)
let deviation = ((capacitance - rating) / rating) * 100.0
logger.debug("Test Capacitor calculated deviation: \(deviation)")
// let positiveDeviation = deviation.ensurePostive()
ratedComparison = .init(
value: Int(rating),
isInRange: abs(deviation) <= 6,
percentDeviation: deviation
)
}
return .test(result: .init(
capacitance: capacitance,
tolerance: .init(capacitance: capacitance),
ratedComparison: ratedComparison
))
}
private func calculateSize(
_ request: Capacitor.Request.SizeRequest,
_ logger: Logger
) throws -> Capacitor.Response {
try request.validate()
let frequency = 60.0
let phaseAngle = acos(request.powerFactor)
let reactiveComponent = request.runningAmps * sin(phaseAngle)
let capacitance = (reactiveComponent * 1_000_000) / (2 * Double.pi * frequency * request.lineVoltage)
logger.debug("Calculate capacitor size capacitance: \(capacitance)")
let standardSize = Self.standardCapacitorSizes.first(where: { $0 >= capacitance })
?? Self.standardCapacitorSizes.last!
logger.debug("Calculate capacitor standard size: \(standardSize)")
return .size(result: .init(
capacitance: capacitance,
standardSize: standardSize,
tolerance: .init(capacitance: capacitance)
))
}
}
private extension Capacitor.Tolerance {
init(capacitance: Double) {
// +- 6% tolerance
self.init(
minimum: capacitance * 0.96,
maximum: capacitance * 1.06
)
}
}
private extension Capacitor.Request.TestRequest {
func validate() throws {
guard startWindingAmps > 0 else {
throw ValidationError(message: "Start winding amps should be greater than 0.")
}
guard runToCommonVoltage > 0 else {
throw ValidationError(message: "Run to common voltage should be greater than 0.")
}
if let ratedCapacitorSize {
guard ratedCapacitorSize > 0 else {
throw ValidationError(message: "Run to common voltage should be greater than 0.")
}
}
}
}
private extension Capacitor.Request.SizeRequest {
func validate() throws {
guard runningAmps > 0 else {
throw ValidationError(message: "Running amps should be greater than 0.")
}
guard lineVoltage > 0 else {
throw ValidationError(message: "Line voltage should be greater than 0.")
}
guard powerFactor > 0, powerFactor < 1.01 else {
throw ValidationError(message: "powerFactor should be greater than 0 and at max 1.")
}
}
}

View File

@@ -0,0 +1,9 @@
import Foundation
extension Double {
func ensurePostive() -> Self {
if self < 0 { return self * -1 }
return self
}
}

View File

@@ -0,0 +1,156 @@
import Foundation
import Logging
import Routes
public extension FilterPressureDrop.Request {
func respond(logger: Logger) async throws -> FilterPressureDrop.Response {
switch self {
case let .basic(request):
return try request.respond(logger: logger)
case let .fanLaw(request):
return try request.respond(logger: logger)
}
}
}
private extension FilterPressureDrop.Request.Basic {
func respond(logger: Logger) throws -> FilterPressureDrop.Response {
try validate()
let cfmPerTon = Double(climateZone.cfmPerTon)
let totalCFM = systemSize * cfmPerTon
// calculate filter area in sq. ft.
let filterArea = (filterWidth * filterHeight) / 144
let filterVelocity = totalCFM / filterArea
let initialPressureDrop = filterType.initialPressureDrop(velocity: filterVelocity)
let maxPressureDrop = 0.15
var warnings = [String]()
if filterVelocity > 350 {
warnings.append(
"""
Face velocity exceeds 350 FPM - consider using a larger filter to reduce pressure drop and
improve system efficiency.
"""
)
}
if initialPressureDrop > (maxPressureDrop / 2) {
warnings.append(
"""
Initial pressure drop is more than 50% of maximum allowable - consider using a larger filter or different
filter type with lower initial pressure drop.
"""
)
}
return .basic(.init(
filterArea: filterArea,
feetPerMinute: filterVelocity,
initialPressureDrop: initialPressureDrop,
maxPressureDrop: maxPressureDrop,
warnings: warnings
))
}
func validate() throws {
guard systemSize > 0 else {
throw ValidationError(message: "System size should be greater than 0.")
}
guard filterWidth > 0 else {
throw ValidationError(message: "Filter width should be greater than 0.")
}
guard filterHeight > 0 else {
throw ValidationError(message: "Filter height should be greater than 0.")
}
}
}
private extension FilterPressureDrop.Request.FanLaw {
func respond(logger: Logger) throws -> FilterPressureDrop.Response {
try validate()
// calculate filter area in square ft.
let filterArea = (filterWidth * filterHeight) / 144
let faceVelocity = designAirflow / filterArea
let velocityRatio = designAirflow / ratedAirflow
let pressureRatio = pow(velocityRatio, 2)
let predictedPressureDrop = ratedPressureDrop * pressureRatio
return .fanLaw(.init(
predictedPressureDrop: predictedPressureDrop,
velocityRatio: velocityRatio,
pressureRatio: pressureRatio,
faceVelocity: faceVelocity
))
}
func validate() throws {
guard filterWidth > 0 else {
throw ValidationError(message: "Filter width should be greater than 0.")
}
guard filterHeight > 0 else {
throw ValidationError(message: "Filter height should be greater than 0.")
}
guard filterDepth > 0 else {
throw ValidationError(message: "Filter depth should be greater than 0.")
}
guard ratedAirflow > 0 else {
throw ValidationError(message: "Rated airflow should be greater than 0.")
}
guard ratedPressureDrop > 0 else {
throw ValidationError(message: "Rated pressure drop should be greater than 0.")
}
guard designAirflow > 0 else {
throw ValidationError(message: "Design airflow should be greater than 0.")
}
}
}
extension FilterPressureDrop.FilterType {
// swiftlint:disable cyclomatic_complexity
func initialPressureDrop(velocity: Double) -> Double {
switch self {
case .fiberglass:
if velocity <= 300 {
return 0.05
} else if velocity <= 350 {
return 0.08
} else {
return 0.12
}
case .pleatedBasic:
if velocity <= 300 {
return 0.08
} else if velocity <= 350 {
return 0.12
} else {
return 0.15
}
case .pleatedBetter:
if velocity <= 300 {
return 0.15
} else if velocity <= 350 {
return 0.18
} else {
return 0.22
}
case .pleatedBest:
if velocity <= 300 {
return 0.20
} else if velocity <= 350 {
return 0.25
} else {
return 0.3
}
}
}
// swiftlint:enable cyclomatic_complexity
}

View File

@@ -0,0 +1,207 @@
import Foundation
import Logging
import OrderedCollections
import Routes
public extension RoomPressure.Request {
static let pascalToIWCMultiplier = 0.004014
static let targetVelocity = 400.0
static let maxVelocity = 600.0
static let minGrilleFreeArea = 0.7
static let standardDuctSizes = OrderedSet([4, 5, 6, 7, 8, 9, 10, 12, 14, 16, 18, 20])
static let standardGrilleHeights = OrderedSet([4, 6, 8, 10, 12, 14, 20])
static let standardGrilleSizes = [
4: OrderedSet([8, 10, 12, 14]),
6: OrderedSet([8, 10, 12, 14]),
8: OrderedSet([10, 12, 14]),
10: OrderedSet([10]),
12: OrderedSet([12]),
14: OrderedSet([14])
]
func respond(logger: Logger) async throws -> RoomPressure.Response {
switch self {
case let .knownAirflow(request):
return try await calculateKnownAirflow(request, logger)
case let .measuredPressure(request):
return try await calculateMeasuredPressure(request, logger)
}
}
private func calculateKnownAirflow(
_ request: RoomPressure.Request.KnownAirflow,
_ logger: Logger
) async throws -> RoomPressure.Response {
try request.validate()
let totalLeakageArea = calculateDoorLeakageArea(
doorWidth: request.doorWidth,
doorHeight: request.doorHeight,
doorUndercut: request.doorUndercut
)
// Net free area (in^2)
let netFreeArea = (request.supplyAirflow / Self.targetVelocity) * 144.0
let grilleDimensions = try getStandardGrilleSize(
requiredArea: netFreeArea,
selectedHeight: request.preferredGrilleHeight.rawValue
)
let (standardDuctSize, actualVelocity) = calculateDuctMetrics(for: request.supplyAirflow)
return .init(
mode: .knownAirflow,
grilleSize: .init(width: grilleDimensions.width, height: grilleDimensions.height, area: netFreeArea / 144),
ductSize: .init(diameter: standardDuctSize, velocity: actualVelocity),
warnings: generateWarnings(
roomPressure: request.targetRoomPressure,
actualVelocity: actualVelocity,
totalLeakageArea: totalLeakageArea,
netFreeArea: netFreeArea
)
)
}
private func calculateMeasuredPressure(
_ request: RoomPressure.Request.MeasuredPressure,
_ logger: Logger
) async throws -> RoomPressure.Response {
try request.validate()
let totalLeakageArea = calculateDoorLeakageArea(
doorWidth: request.doorWidth,
doorHeight: request.doorHeight,
doorUndercut: request.doorUndercut
)
let pressureInchesWaterColumn = request.measuredRoomPressure * Self.pascalToIWCMultiplier
let calculatedAirflow = totalLeakageArea * 4005 * sqrt(pressureInchesWaterColumn)
// in^2
let netFreeArea = (calculatedAirflow / Self.targetVelocity) * 144
let grilleDimensions = try getStandardGrilleSize(
requiredArea: netFreeArea,
selectedHeight: request.preferredGrilleHeight.rawValue
)
let (standardDuctSize, actualVelocity) = calculateDuctMetrics(for: calculatedAirflow)
return .init(
mode: .measuredPressure,
grilleSize: .init(width: grilleDimensions.width, height: grilleDimensions.height, area: netFreeArea / 144),
ductSize: .init(diameter: standardDuctSize, velocity: actualVelocity),
calculatedAirflow: calculatedAirflow,
warnings: generateWarnings(
roomPressure: request.measuredRoomPressure,
actualVelocity: actualVelocity,
totalLeakageArea: totalLeakageArea,
netFreeArea: netFreeArea
)
)
}
private func calculateDuctMetrics(for airflow: Double) -> (diameter: Int, velocity: Double) {
let ductArea = airflow / Self.targetVelocity
let ductDiameter = sqrt((4 * ductArea) / Double.pi) * 12
let standardDuctSize = getStandardDuctSize(for: Int(ductDiameter))
let actualVelocity = airflow / (Double.pi * pow(Double(standardDuctSize) / 24, 2))
return (standardDuctSize, actualVelocity)
}
private func generateWarnings(
roomPressure: Double,
actualVelocity: Double,
totalLeakageArea: Double,
netFreeArea: Double
) -> [String] {
var warnings = [String]()
if roomPressure > 3 {
warnings.append(
"Room pressure exceeds 3 Pascals - consider reducing pressure differntial."
)
}
if totalLeakageArea > netFreeArea / 144 * 0.5 {
warnings.append(
"Door leakage area is significant - consider reducing gaps or increasing grille size."
)
}
if actualVelocity > Self.maxVelocity {
warnings.append(
"Return air velocity exceeds maximum recommended velocity - consider next size up duct."
)
}
return []
}
private func getStandardDuctSize(for calculatedSize: Int) -> Int {
Self.standardDuctSizes.sorted()
.first { $0 >= calculatedSize }
?? 20
}
private func getStandardGrilleSize(requiredArea: Double, selectedHeight: Int) throws -> (width: Int, height: Int) {
guard let availableSizes = Self.standardGrilleSizes[selectedHeight] else {
throw RoomPressureError.invalidPreferredHeight
}
if let width = availableSizes.first(where: {
Double($0) * Double(selectedHeight) * Self.minGrilleFreeArea >= (requiredArea / Self.minGrilleFreeArea)
}) {
return (width, selectedHeight)
}
// If no width matches return largest for the selectedHeight.
if let largestSize = availableSizes.last { return (largestSize, selectedHeight) }
return (14, selectedHeight)
}
// Calculate door leakage area in sq. ft.
private func calculateDoorLeakageArea(doorWidth: Double, doorHeight: Double, doorUndercut: Double) -> Double {
let doorLeakageArea = (doorWidth * doorUndercut) / 144.0
let doorPerimiterLeakage = ((2 * doorHeight + doorWidth) * 0.125) / 144.0
return doorLeakageArea + doorPerimiterLeakage
}
enum RoomPressureError: Error {
case invalidPreferredHeight
}
}
extension RoomPressure.Request.KnownAirflow {
func validate() throws {
guard targetRoomPressure > 0 else {
throw ValidationError(message: "Target room pressure should be greater than 0.")
}
guard doorWidth > 0 else {
throw ValidationError(message: "Door width should be greater than 0.")
}
guard doorHeight > 0 else {
throw ValidationError(message: "Door height should be greater than 0.")
}
guard doorUndercut > 0 else {
throw ValidationError(message: "Door undercut should be greater than 0.")
}
guard supplyAirflow > 0 else {
throw ValidationError(message: "Supply airflow should be greater than 0.")
}
}
}
extension RoomPressure.Request.MeasuredPressure {
func validate() throws {
guard measuredRoomPressure > 0 else {
throw ValidationError(message: "MeasuredPressure room pressure should be greater than 0.")
}
guard doorWidth > 0 else {
throw ValidationError(message: "Door width should be greater than 0.")
}
guard doorHeight > 0 else {
throw ValidationError(message: "Door height should be greater than 0.")
}
guard doorUndercut > 0 else {
throw ValidationError(message: "Door undercut should be greater than 0.")
}
}
}

View File

@@ -0,0 +1,3 @@
struct ValidationError: Error {
let message: String
}

View File

@@ -0,0 +1,67 @@
import Foundation
public enum ClimateZone {
public enum ZoneType: String, CaseIterable, Codable, Equatable, Sendable {
// NOTE: Keep in this order.
case hotHumid
case moist
case dry
case marine
// FIX: Return ZoneIdentifiers.
public var zoneIdentifiers: [String] {
switch self {
case .dry:
return ["2B", "3B", "4B", "5B", "6B", "7B"]
case .hotHumid:
return ["1A", "2A"]
case .marine:
return ["3C", "4C"]
case .moist:
return ["3A", "4A", "5A", "6A", "7A"]
}
}
public var cfmPerTon: Int {
switch self {
case .dry: return 450
case .hotHumid: return 350
case .marine, .moist: return 400
}
}
public var label: String {
return "\(self == .hotHumid ? "Hot Humid" : rawValue.capitalized) (\(zoneIdentifiers.joined(separator: ", ")))"
}
/// Represents climate zone identifiers.
public enum ZoneIdentifier: String, CaseIterable, Codable, Equatable, Sendable {
// A zones (hotHumid)
case oneA = "1A"
case twoA = "2A"
// A zones (moist)
case threeA = "3A"
case fourA = "4A"
case fiveA = "5A"
case sixA = "6A"
case sevenA = "7A"
// B zones (dry)
case twoB = "2B"
case threeB = "3B"
case fourB = "4B"
case fiveB = "5B"
case sixB = "6B"
case sevenB = "7B"
// C zones (marine)
case threeC = "3C"
case fourC = "4C"
public var label: String { rawValue }
}
}
}

View File

@@ -0,0 +1,11 @@
/// Represents a location coordinates.
public struct Coordinates: Codable, Equatable, Sendable {
public let latitude: Double
public let longitude: Double
public init(latitude: Double, longitude: Double) {
self.latitude = latitude
self.longitude = longitude
}
}

View File

@@ -0,0 +1,11 @@
/// Represents design temperature conditions.
public struct DesignTemperatures: Codable, Equatable, Sendable {
public let cooling: Double
public let heating: Double
public init(cooling: Double, heating: Double) {
self.cooling = cooling
self.heating = heating
}
}

View File

@@ -0,0 +1,27 @@
import Foundation
public struct Location: Codable, Equatable, Sendable {
public let city: String
public let state: String
public let zipCode: String
public let stateCode: String
public let county: String
public let coordinates: Coordinates
public init(
city: String,
state: String,
stateCode: String,
zipCode: String,
county: String,
coordinates: Coordinates
) {
self.city = city
self.zipCode = zipCode
self.state = state
self.stateCode = stateCode
self.county = county
self.coordinates = coordinates
}
}

View File

@@ -0,0 +1,74 @@
import Dependencies
import DependenciesMacros
import Foundation
public extension DependencyValues {
var locationClient: LocationClient {
get { self[LocationClient.self] }
set { self[LocationClient.self] = newValue }
}
}
@DependencyClient
public struct LocationClient: Sendable {
public var search: @Sendable (Int) async throws -> [Response]
// TODO: Add ClimateZone.ZoneIdentifier??
public struct Response: Codable, Equatable, Sendable {
public let city: String
public let latitude: String
public let longitude: String
public let zipCode: String
public let state: String
public let stateCode: String
public let county: String
public init(
city: String,
latitude: String,
longitude: String,
zipCode: String,
state: String,
stateCode: String,
county: String
) {
self.city = city
self.latitude = latitude
self.longitude = longitude
self.zipCode = zipCode
self.state = state
self.stateCode = stateCode
self.county = county
}
private enum CodingKeys: String, CodingKey {
case city
case latitude
case longitude
case zipCode = "postal_code"
case state
case stateCode = "state_code"
case county = "province"
}
}
}
extension LocationClient: TestDependencyKey {
public static let testValue: LocationClient = Self()
}
#if DEBUG
public extension LocationClient.Response {
static let mock = Self(
city: "Monroe",
latitude: "39.4413000",
longitude: "-84.3652000",
zipCode: "45050",
state: "Ohio",
stateCode: "OH",
county: "Butler"
)
}
#endif

View File

@@ -0,0 +1,106 @@
public enum AtticVentilation {
public static let description = """
Calculate attic ventilation requirements and assess current conditions.
"""
public struct Request: Codable, Equatable, Sendable {
public let pressureDifferential: Double
public let outdoorTemperature: Double
public let outdoorDewpoint: Double
public let atticTemperature: Double
public let atticDewpoint: Double
public let atticFloorArea: Double
public let existingIntakeArea: Double?
public let existingExhaustArea: Double?
public init(
pressureDifferential: Double,
outdoorTemperature: Double,
outdoorDewpoint: Double,
atticTemperature: Double,
atticDewpoint: Double,
atticFloorArea: Double,
existingIntakeArea: Double? = nil,
existingExhaustArea: Double? = nil
) {
self.pressureDifferential = pressureDifferential
self.outdoorTemperature = outdoorTemperature
self.outdoorDewpoint = outdoorDewpoint
self.atticTemperature = atticTemperature
self.atticDewpoint = atticDewpoint
self.atticFloorArea = atticFloorArea
self.existingIntakeArea = existingIntakeArea
self.existingExhaustArea = existingExhaustArea
}
}
public struct Response: Codable, Equatable, Sendable {
public let pressureDifferential: Double
public let temperatureDifferential: Double
public let dewpointDifferential: Double
public let stackEffect: Double
public let requiredVentilationArea: RequiredVentilationArea
public let recommendations: [String]
public let warnings: [String]
public let ventilationStatus: VentilationStatus
public init(
pressureDifferential: Double,
temperatureDifferential: Double,
dewpointDifferential: Double,
stackEffect: Double,
requiredVentilationArea: AtticVentilation.RequiredVentilationArea,
recommendations: [String],
warnings: [String],
ventilationStatus: AtticVentilation.VentilationStatus
) {
self.pressureDifferential = pressureDifferential
self.temperatureDifferential = temperatureDifferential
self.dewpointDifferential = dewpointDifferential
self.stackEffect = stackEffect
self.requiredVentilationArea = requiredVentilationArea
self.recommendations = recommendations
self.warnings = warnings
self.ventilationStatus = ventilationStatus
}
}
public enum VentilationStatus: String, Codable, CaseIterable, Equatable, Sendable {
case adequate
case inadequate
case critical
}
public struct RequiredVentilationArea: Codable, Equatable, Sendable {
public let intake: Double
public let exhaust: Double
public init(intake: Double, exhaust: Double) {
self.intake = intake
self.exhaust = exhaust
}
}
}
#if DEBUG
public extension AtticVentilation.Response {
static let mock = Self(
pressureDifferential: 4,
temperatureDifferential: 15,
dewpointDifferential: 10,
stackEffect: 2,
requiredVentilationArea: .init(intake: 4.9, exhaust: 3.3),
recommendations: [
"Install 4.9 sq. ft. of intake ventilation",
"Install 3.3 sq. ft. of exhaust ventilation",
"Consider adding exhaust ventilation to balance pressure"
],
warnings: ["High pressure differential - may indicate blocked vents or insufficient ventilation."],
ventilationStatus: AtticVentilation.VentilationStatus.allCases.randomElement()!
)
}
#endif

View File

@@ -0,0 +1,125 @@
public enum Capacitor {
public static let description = """
Calculate run capacitor values based on electrical measurements.
"""
public enum Mode: String, CaseIterable, Codable, Equatable, Sendable {
case size
case test
}
public enum Request: Codable, Equatable, Sendable {
case size(SizeRequest)
case test(TestRequest)
public struct SizeRequest: Codable, Equatable, Sendable {
public let runningAmps: Double
public let lineVoltage: Double
public let powerFactor: Double
public init(runningAmps: Double, lineVoltage: Double, powerFactor: Double) {
self.runningAmps = runningAmps
self.lineVoltage = lineVoltage
self.powerFactor = powerFactor
}
}
public struct TestRequest: Codable, Equatable, Sendable {
public let startWindingAmps: Double
public let runToCommonVoltage: Double
public let ratedCapacitorSize: Int?
public init(startWindingAmps: Double, runToCommonVoltage: Double, ratedCapacitorSize: Int? = nil) {
self.startWindingAmps = startWindingAmps
self.runToCommonVoltage = runToCommonVoltage
self.ratedCapacitorSize = ratedCapacitorSize
}
}
}
public enum Response: Codable, Equatable, Sendable {
case size(result: SizeResponse)
case test(result: TestResponse)
public struct SizeResponse: Codable, Equatable, Sendable {
public let capacitance: Double
public let standardSize: Double
public let tolerance: Tolerance
public init(capacitance: Double, standardSize: Double, tolerance: Capacitor.Tolerance) {
self.capacitance = capacitance
self.standardSize = standardSize
self.tolerance = tolerance
}
}
public struct TestResponse: Codable, Equatable, Sendable {
public let capacitance: Double
public let tolerance: Tolerance
public let ratedComparison: RatedComparison?
public init(
capacitance: Double,
tolerance: Capacitor.Tolerance,
ratedComparison: Capacitor.RatedComparison? = nil
) {
self.capacitance = capacitance
self.tolerance = tolerance
self.ratedComparison = ratedComparison
}
}
}
public struct Tolerance: Codable, Equatable, Sendable {
public let minimum: Double
public let maximum: Double
public init(minimum: Double, maximum: Double) {
self.minimum = minimum
self.maximum = maximum
}
}
public struct RatedComparison: Codable, Equatable, Sendable {
public let value: Int
public let isInRange: Bool
public let percentDeviation: Double
public init(value: Int, isInRange: Bool, percentDeviation: Double) {
self.value = value
self.isInRange = isInRange
self.percentDeviation = percentDeviation
}
}
}
#if DEBUG
public extension Capacitor.Response {
static func mock(mode: Capacitor.Mode) -> Self {
switch mode {
case .size:
return .size(result: .init(
capacitance: 57.5,
standardSize: 60,
tolerance: .init(minimum: 54.1, maximum: 61)
))
case .test:
return .test(result: .init(
capacitance: 34.8,
tolerance: .init(minimum: 32.7, maximum: 36.9),
ratedComparison: .init(value: 35, isInRange: Bool.random(), percentDeviation: 0.6)
))
}
}
}
#endif

View File

@@ -1,31 +0,0 @@
public enum ClimateZone: String, Codable, Equatable, Sendable {
case dry
case hotHumid
case marine
case moist
public var zoneIdentifiers: [String] {
switch self {
case .dry:
return ["2B", "3B", "4B", "5B", "6B", "7B"]
case .hotHumid:
return ["1A", "2A"]
case .marine:
return ["3C", "4C"]
case .moist:
return ["3A", "4A", "5A", "6A", "7A"]
}
}
public var cfmPerTon: Int {
switch self {
case .dry: return 450
case .hotHumid: return 350
case .marine, .moist: return 400
}
}
public var label: String {
return "\(rawValue.capitalized) (\(zoneIdentifiers.joined(separator: ",")))"
}
}

View File

@@ -2,6 +2,10 @@ import Foundation
public enum DehumidifierSize {
public static let description = """
Calculate dehumidifier size based on latent load and performance data.
"""
/// Represents the request for determining dehumidifier size based on
/// latent load and indoor conditions.
public struct Request: Codable, Equatable, Sendable {

View File

@@ -1,20 +1,29 @@
public enum FilterPressureDrop {
public static let description = """
Calculate filter pressure drop and sizing based on system requirements.
"""
public enum Mode: String, CaseIterable, Codable, Equatable, Sendable {
case basic
case fanLaw
}
public enum Request: Codable, Equatable, Sendable {
case basic(Basic)
case fanLaw(FanLaw)
public struct Basic: Codable, Equatable, Sendable {
let systemSize: HVACSystemSize
let climateZone: ClimateZone
let filterType: FilterType
let filterWidth: Double
let filterHeight: Double
public let systemSize: Double
public let climateZone: ClimateZone.ZoneType
public let filterType: FilterType
public let filterWidth: Double
public let filterHeight: Double
public init(
systemSize: HVACSystemSize,
climateZone: ClimateZone,
systemSize: Double,
climateZone: ClimateZone.ZoneType,
filterType: FilterPressureDrop.FilterType,
filterWidth: Double,
filterHeight: Double
@@ -29,12 +38,12 @@ public enum FilterPressureDrop {
public struct FanLaw: Codable, Equatable, Sendable {
let filterWidth: Double
let filterHeight: Double
let filterDepth: Double
let ratedAirflow: Double
let ratedPressureDrop: Double
let designAirflow: Double
public let filterWidth: Double
public let filterHeight: Double
public let filterDepth: Double
public let ratedAirflow: Double
public let ratedPressureDrop: Double
public let designAirflow: Double
public init(
filterWidth: Double,
@@ -54,22 +63,30 @@ public enum FilterPressureDrop {
}
}
public enum Result: Codable, Equatable, Sendable {
public enum Response: Codable, Equatable, Sendable {
case basic(Basic)
case fanLaw(FanLaw)
public struct Basic: Codable, Equatable, Sendable {
let filterArea: Double
let feetPerMinute: Double
let initialPressureDrop: Double
let maxPressureDrop: Double
public let filterArea: Double
public let feetPerMinute: Double
public let initialPressureDrop: Double
public let maxPressureDrop: Double
public let warnings: [String]
public init(filterArea: Double, feetPerMinute: Double, initialPressureDrop: Double, maxPressureDrop: Double) {
public init(
filterArea: Double,
feetPerMinute: Double,
initialPressureDrop: Double,
maxPressureDrop: Double,
warnings: [String]
) {
self.filterArea = filterArea
self.feetPerMinute = feetPerMinute
self.initialPressureDrop = initialPressureDrop
self.maxPressureDrop = maxPressureDrop
self.warnings = warnings
}
}
@@ -94,7 +111,7 @@ public enum FilterPressureDrop {
}
}
public enum FilterType: String, Codable, Equatable, Sendable {
public enum FilterType: String, CaseIterable, Codable, Equatable, Sendable {
case fiberglass
case pleatedBasic
case pleatedBetter
@@ -110,3 +127,34 @@ public enum FilterPressureDrop {
}
}
}
#if DEBUG
public extension FilterPressureDrop.Response {
static func mock(mode: FilterPressureDrop.Mode) -> Self {
switch mode {
case .basic:
return .basic(.init(
filterArea: 3.47,
feetPerMinute: 230,
initialPressureDrop: 0.2,
maxPressureDrop: 0.15,
warnings: [
"""
Intial pressure drop is more than 50% of maximum allowable.
Consider using a larger filter or different type with lower intial pressure drop.
"""
]
))
case .fanLaw:
return .fanLaw(.init(
predictedPressureDrop: 0.127,
velocityRatio: 1.13,
pressureRatio: 1.27,
faceVelocity: 259
))
}
}
}
#endif

View File

@@ -3,6 +3,10 @@ import PsychrometricClient
public enum HVACSystemPerformance {
public static let description = """
Analyze HVAC system performance and capacity.
"""
public struct Request: Codable, Equatable, Sendable {
public let altitude: Double?

View File

@@ -1,4 +1,4 @@
public enum HVACSystemSize: Double, Codable, Equatable, Sendable {
public enum HVACSystemSize: Double, CaseIterable, Codable, Equatable, Sendable {
case one = 1
case oneAndAHalf = 1.5
case two = 2

View File

@@ -2,6 +2,11 @@ import Foundation
import PsychrometricClient
public enum MoldRisk {
public static let description = """
Assess mold risk based on indoor conditions.
"""
public struct Request: Codable, Equatable, Sendable {
public let temperature: Double

View File

@@ -1,5 +1,9 @@
public enum RoomPressure {
public static let description = """
Calculate return grille and duct sizing for room pressure balancing.
"""
public enum Mode: String, CaseIterable, Codable, Equatable, Sendable {
case knownAirflow
case measuredPressure
@@ -68,17 +72,20 @@ public enum RoomPressure {
public struct Response: Codable, Equatable, Sendable {
public let mode: Mode
public let grilleSize: GrilleSize
public let ductSize: DuctSize
public let calculatedAirflow: Double?
public let warnings: [String]
public init(
mode: Mode,
grilleSize: RoomPressure.Response.GrilleSize,
ductSize: RoomPressure.Response.DuctSize,
calculatedAirflow: Double? = nil,
warnings: [String]
) {
self.mode = mode
self.grilleSize = grilleSize
self.ductSize = ductSize
self.calculatedAirflow = calculatedAirflow
@@ -87,10 +94,10 @@ public enum RoomPressure {
public struct DuctSize: Codable, Equatable, Sendable {
public let diameter: Double
public let diameter: Int
public let velocity: Double
public init(diameter: Double, velocity: Double) {
public init(diameter: Int, velocity: Double) {
self.diameter = diameter
self.velocity = velocity
}
@@ -99,11 +106,11 @@ public enum RoomPressure {
public struct GrilleSize: Codable, Equatable, Sendable {
public let width: Double
public let height: Double
public let width: Int
public let height: Int
public let area: Double
public init(width: Double, height: Double, area: Double) {
public init(width: Int, height: Int, area: Double) {
self.width = width
self.height = height
self.area = area
@@ -119,8 +126,21 @@ public enum RoomPressure {
case ten = 10
case twelve = 12
case fourteen = 14
case twenty = 20
// case twenty = 20
public var label: String { "\(rawValue)\"" }
}
}
#if DEBUG
public extension RoomPressure.Response {
static let mock = Self(
mode: .knownAirflow,
grilleSize: .init(width: 14, height: 8, area: 72),
ductSize: .init(diameter: 10, velocity: 367),
warnings: [
"Duct leakage area is significant - consider reducing gaps or increasing grille size."
]
)
}
#endif

View File

@@ -1,4 +1,5 @@
import CasePaths
@_exported import CoreModels
import Foundation
import PsychrometricClient
@preconcurrency import URLRouting
@@ -13,6 +14,10 @@ public enum SiteRoute: Equatable, Sendable {
Route(.case(Self.api)) {
Api.router
}
Route(.case(Self.health)) {
Path { "health" }
Method.get
}
Route(.case(Self.view)) {
View.router
}
@@ -23,18 +28,47 @@ public extension SiteRoute {
enum Api: Equatable, Sendable {
case calculateAtticVentilation(AtticVentilation.Request)
case calculateCapacitor(Capacitor.Request)
case calculateDehumidifierSize(DehumidifierSize.Request)
case calculateFilterPressureDrop(FilterPressureDrop.Request)
case calculateHVACSystemPerformance(HVACSystemPerformance.Request)
case calculateMoldRisk(MoldRisk.Request)
case calculateRoomPressure(RoomPressure.Request)
static let rootPath = Path { "api"; "v1" }
public static let router = OneOf {
Route(.case(Self.calculateAtticVentilation)) {
Path { "api"; "v1"; "calculateAtticPressure" }
Method.post
Body(.json(AtticVentilation.Request.self))
}
Route(.case(Self.calculateCapacitor)) {
Path { "api"; "v1"; "calculateRoomPressure" }
Method.post
OneOf {
Body(.json(Capacitor.Request.SizeRequest.self))
.map(.case(Capacitor.Request.size))
Body(.json(Capacitor.Request.TestRequest.self))
.map(.case(Capacitor.Request.test))
}
}
Route(.case(Self.calculateDehumidifierSize)) {
Path { "api"; "v1"; "calculateDehumidifierSize" }
Method.post
Body(.json(DehumidifierSize.Request.self))
}
Route(.case(Self.calculateFilterPressureDrop)) {
Path { "api"; "v1"; "calculateFilterPressureDrop" }
Method.post
OneOf {
Body(.json(FilterPressureDrop.Request.Basic.self))
.map(.case(FilterPressureDrop.Request.basic))
Body(.json(FilterPressureDrop.Request.FanLaw.self))
.map(.case(FilterPressureDrop.Request.fanLaw))
}
}
Route(.case(Self.calculateHVACSystemPerformance)) {
Path { "api"; "v1"; "calculateHVACSystemPerformance" }
Method.post
@@ -45,6 +79,16 @@ public extension SiteRoute {
Method.post
Body(.json(MoldRisk.Request.self))
}
Route(.case(Self.calculateRoomPressure)) {
Path { "api"; "v1"; "calculateRoomPressure" }
Method.post
OneOf {
Body(.json(RoomPressure.Request.KnownAirflow.self))
.map(.case(RoomPressure.Request.knownAirflow))
Body(.json(RoomPressure.Request.MeasuredPressure.self))
.map(.case(RoomPressure.Request.measuredPressure))
}
}
}
}
}
@@ -53,7 +97,10 @@ public extension SiteRoute {
enum View: Equatable, Sendable {
case index
case atticVentilation(AtticVentilation)
case capacitor(Capacitor)
case dehumidifierSize(DehumidifierSize)
case filterPressureDrop(FilterPressureDrop)
case hvacSystemPerformance(HVACSystemPerformance)
case moldRisk(MoldRisk)
case roomPressure(RoomPressure)
@@ -62,9 +109,18 @@ public extension SiteRoute {
Route(.case(Self.index)) {
Method.get
}
Route(.case(Self.atticVentilation)) {
AtticVentilation.router
}
Route(.case(Self.capacitor)) {
Capacitor.router
}
Route(.case(Self.dehumidifierSize)) {
DehumidifierSize.router
}
Route(.case(Self.filterPressureDrop)) {
FilterPressureDrop.router
}
Route(.case(Self.hvacSystemPerformance)) {
HVACSystemPerformance.router
}
@@ -76,6 +132,81 @@ public extension SiteRoute {
}
}
public enum AtticVentilation: Equatable, Sendable {
case index
case submit(Routes.AtticVentilation.Request)
static let rootPath = "attic-ventilation"
public static let router = OneOf {
Route(.case(Self.index)) {
Path { rootPath }
Method.get
}
Route(.case(Self.submit)) {
Path { rootPath }
Method.post
Body {
FormData {
Field("pressureDifferential") { Double.parser() }
Field("outdoorTemperature") { Double.parser() }
Field("outdoorDewpoint") { Double.parser() }
Field("atticTemperature") { Double.parser() }
Field("atticDewpoint") { Double.parser() }
Field("atticFloorArea") { Double.parser() }
Optionally { Field("existingIntakeArea") { Double.parser() } }
Optionally { Field("existingExhaustArea") { Double.parser() } }
}
.map(.memberwise(Routes.AtticVentilation.Request.init))
}
}
}
}
public enum Capacitor: Equatable, Sendable {
case index(mode: Routes.Capacitor.Mode? = nil)
case submit(Routes.Capacitor.Request)
public static var index: Self { .index() }
static let rootPath = "capacitor-calculator"
public static let router = OneOf {
Route(.case(Self.index)) {
Path { rootPath }
Method.get
Query {
Optionally { Field("mode") { Routes.Capacitor.Mode.parser() } }
}
}
Route(.case(Self.submit)) {
Path { rootPath }
Method.post
Body {
OneOf {
FormData {
Field("runningAmps") { Double.parser() }
Field("lineVoltage") { Double.parser() }
Field("powerFactor") { Double.parser() }
}
.map(.memberwise(Routes.Capacitor.Request.SizeRequest.init))
.map(.case(Routes.Capacitor.Request.size))
FormData {
Field("startWindingAmps") { Double.parser() }
Field("runToCommonVoltage") { Double.parser() }
Optionally {
Field("ratedCapacitorSize") { Int.parser() }
}
}
.map(.memberwise(Routes.Capacitor.Request.TestRequest.init))
.map(.case(Routes.Capacitor.Request.test))
}
}
}
}
}
public enum DehumidifierSize: Equatable, Sendable {
case index
case submit(Routes.DehumidifierSize.Request)
@@ -102,6 +233,53 @@ public extension SiteRoute {
}
}
public enum FilterPressureDrop: Equatable, Sendable {
case index(mode: Routes.FilterPressureDrop.Mode? = nil)
case submit(Routes.FilterPressureDrop.Request)
public static let index = Self.index()
static let rootPath = "filter-pressure-drop"
public static let router = OneOf {
Route(.case(Self.index)) {
Path { rootPath }
Method.get
Query {
Optionally { Field("mode") { Routes.FilterPressureDrop.Mode.parser() } }
}
}
Route(.case(Self.submit)) {
Path { rootPath }
Method.post
Body {
OneOf {
FormData {
Field("systemSize") { Double.parser() }
Field("climateZone") { ClimateZone.ZoneType.parser() }
Field("filterType") { Routes.FilterPressureDrop.FilterType.parser() }
Field("filterWidth") { Double.parser() }
Field("filterHeight") { Double.parser() }
}
.map(.memberwise(Routes.FilterPressureDrop.Request.Basic.init))
.map(.case(Routes.FilterPressureDrop.Request.basic))
FormData {
Field("filterWidth") { Double.parser() }
Field("filterHeight") { Double.parser() }
Field("filterDepth") { Double.parser() }
Field("ratedAirflow") { Double.parser() }
Field("ratedPressureDrop") { Double.parser() }
Field("designAirflow") { Double.parser() }
}
.map(.memberwise(Routes.FilterPressureDrop.Request.FanLaw.init))
.map(.case(Routes.FilterPressureDrop.Request.fanLaw))
}
}
}
}
}
public enum HVACSystemPerformance: Equatable, Sendable {
case index
case submit(Routes.HVACSystemPerformance.Request)
@@ -164,6 +342,7 @@ public extension SiteRoute {
public enum RoomPressure: Equatable, Sendable {
case index(mode: Routes.RoomPressure.Mode? = nil)
case submit(Routes.RoomPressure.Request)
public static var index: Self { .index() }
@@ -174,7 +353,39 @@ public extension SiteRoute {
Path { rootPath }
Method.get
Query {
Optionally { Field("form") { Routes.RoomPressure.Mode.parser() } }
Optionally { Field("mode") { Routes.RoomPressure.Mode.parser() } }
}
}
Route(.case(Self.submit)) {
Path { rootPath }
Method.post
Body {
OneOf {
FormData {
Field("targetRoomPressure") { Double.parser() }
Field("doorWidth") { Double.parser() }
Field("doorHeight") { Double.parser() }
Field("doorUndercut") { Double.parser() }
Field("supplyAirflow") { Double.parser() }
Field("preferredGrilleHeight") {
Routes.RoomPressure.CommonReturnGrilleHeight.parser()
}
}
.map(.memberwise(Routes.RoomPressure.Request.KnownAirflow.init))
.map(.case(Routes.RoomPressure.Request.knownAirflow))
FormData {
Field("measuredRoomPressure") { Double.parser() }
Field("doorWidth") { Double.parser() }
Field("doorHeight") { Double.parser() }
Field("doorUndercut") { Double.parser() }
Field("preferredGrilleHeight") {
Routes.RoomPressure.CommonReturnGrilleHeight.parser()
}
}
.map(.memberwise(Routes.RoomPressure.Request.MeasuredPressure.init))
.map(.case(Routes.RoomPressure.Request.measuredPressure))
}
}
}
}

View File

@@ -7,7 +7,7 @@ private let numberFormatter: NumberFormatter = {
return formatter
}()
extension String.StringInterpolation {
public extension String.StringInterpolation {
mutating func appendInterpolation(double: Double, fractionDigits: Int = 2) {
let formatter = NumberFormatter()
formatter.numberStyle = .decimal

View File

@@ -1,4 +1,5 @@
import Elementary
import Routes
public struct PrimaryButton: HTML, Sendable {
let label: String
@@ -75,3 +76,70 @@ public struct ResetButton: HTML, Sendable {
}
}
}
public struct Toggle: HTML {
let isOn: Bool
// Left hand side / 'on' label.
let onLabel: String
// Applied to the rhs when the toggle is consider 'off'.
let onAttributes: [HTMLAttribute<HTMLTag.button>]
// Right hand side / 'off' label.
let offLabel: String
// Applied to the left hand side when the toggle is consider 'on'.
let offAttributes: [HTMLAttribute<HTMLTag.button>]
public init(
isOn: Bool,
onLabel: String,
onAttributes: [HTMLAttribute<HTMLTag.button>],
offLabel: String,
offAttributes: [HTMLAttribute<HTMLTag.button>]
) {
self.isOn = isOn
self.onLabel = onLabel
self.onAttributes = onAttributes
self.offLabel = offLabel
self.offAttributes = offAttributes
}
public var content: some HTML<HTMLTag.div> {
div(.class("flex items-center gap-x-0")) {
switch isOn {
case true:
SecondaryButton(label: onLabel)
.attributes(.class("rounded-s-lg"), .disabled)
PrimaryButton(label: offLabel)
.attributes(contentsOf: offAttributes + [.class("rounded-e-lg")])
case false:
PrimaryButton(label: onLabel)
.attributes(contentsOf: onAttributes + [.class("rounded-s-lg")])
SecondaryButton(label: offLabel)
.attributes(.class("rounded-e-lg"), .disabled)
}
}
}
}
public extension Array where Element == HTMLAttribute<HTMLTag.button> {
static func hxDefaults(
_ attributes: HTMLAttribute<HTMLTag.button>...
) -> Self {
[
.hx.target("#content"),
.hx.pushURL(true)
] + attributes
}
static func hxDefaults(
get route: SiteRoute.View
) -> Self {
.hxDefaults(.hx.get(route: route))
}
}

View File

@@ -50,9 +50,9 @@ public struct Input: HTML, Sendable {
input(
.id(id), .placeholder(placeholder), .name(name ?? id),
.class("""
w-full px-4 py-2 border rounded-md
w-full px-4 py-2 border rounded-md min-h-11
focus:ring-2 focus:ring-yellow-800 focus:border-yellow-800
placeholder-shown:border-gray-300 placeholder-shown:dark:border-gray-400
placeholder-shown:!border-gray-400
invalid:border-red-500 out-of-range:border-red-500
""")
)
@@ -84,3 +84,57 @@ public struct InputLabel<InputLabel: HTML>: HTML {
}
extension InputLabel: Sendable where InputLabel: Sendable {}
public struct Select<T>: HTML {
let id: String
let name: String?
let values: [T]
let label: @Sendable (T) -> String
let value: @Sendable (T) -> String
public init(
id: String,
name: String? = nil,
values: [T],
label: @escaping @Sendable (T) -> String,
value: @escaping @Sendable (T) -> String
) {
self.id = id
self.name = name
self.values = values
self.label = label
self.value = value
}
public var content: some HTML<HTMLTag.select> {
select(
.id(id),
.name(name ?? id),
.class("w-full rounded-md border px-4 py-2 min-h-11")
) {
for value in values {
option(.value(self.value(value))) { label(value) }
}
}
}
}
extension Select: Sendable where T: Sendable {}
public extension Select where T: CaseIterable, T: RawRepresentable, T.RawValue: CustomStringConvertible {
init(
for type: T.Type,
id: String,
name: String? = nil,
label: @escaping @Sendable (T) -> String
) {
self.init(
id: id,
name: name,
values: Array(T.allCases),
label: label,
value: { $0.rawValue.description }
)
}
}

View File

@@ -10,11 +10,16 @@ public struct Note: HTML, Sendable {
}
public var content: some HTML {
div(.class("mt-8 p-4 bg-gray-100 dark:bg-gray-700 rounded-md shadow-md border border-blue-500")) {
p(.class("text-sm text-blue-500")) {
span(.class("font-extrabold pe-2")) { label }
text
}
div(
.class(
"""
mt-8 p-4 bg-gray-100 dark:bg-gray-700 rounded-md shadow-md
border border-blue-500 text-blue-500 text-sm
"""
)
) {
p(.class("font-extrabold mb-3")) { label }
p(.class("px-6")) { text }
}
}
}

View File

@@ -0,0 +1,34 @@
import Elementary
public struct Row<Body: HTML>: HTML {
let body: Body
public init(
@HTMLBuilder body: () -> Body
) {
self.body = body()
}
public var content: some HTML<HTMLTag.div> {
div(.class("flex justify-between")) {
body
}
}
}
public extension Row
where Body == _HTMLTuple2<HTMLElement<HTMLTag.span, HTMLText>, HTMLElement<HTMLTag.span, HTMLText>>
{
init(
label: String,
value: String
) {
self.init {
span(.class("font-bold")) { label }
span { value }
}
}
}
extension Row: Sendable where Body: Sendable {}

View File

@@ -44,6 +44,7 @@ public struct SVGSize: Sendable {
public enum SVGType: Sendable, CaseIterable {
case calculator
case checkCircle
case droplets
case exclamation
case funnel
@@ -60,6 +61,7 @@ public enum SVGType: Sendable, CaseIterable {
public func html(_ size: SVGSize) -> some HTML {
switch self {
case .calculator: return calculatorSvg(size: size)
case .checkCircle: return checkCircleSvg(size: size)
case .droplets: return dropletsSvg(size: size)
case .exclamation: return exclamationSvg(size: size)
case .funnel: return funnelSvg(size: size)
@@ -80,6 +82,14 @@ public enum SVGType: Sendable, CaseIterable {
// swiftlint:disable line_length
private func checkCircleSvg(size: SVGSize) -> HTMLRaw {
HTMLRaw("""
<svg xmlns="http://www.w3.org/2000/svg" width="\(size.width)" height="\(size.height)" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-check-big">
<path d="M21.801 10A10 10 0 1 1 17 3.335"/><path d="m9 11 3 3L22 4"/>
</svg>
""")
}
private func houseSvg(size: SVGSize) -> HTMLRaw {
HTMLRaw("""
<svg xmlns="http://www.w3.org/2000/svg" width="\(size.width)" height="\(size.height)" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-house">

View File

@@ -0,0 +1,25 @@
import Elementary
public struct VerticalGroup: HTML, Sendable {
let label: String
let value: String
let valueLabel: String?
public init(label: String, value: String, valueLabel: String? = nil) {
self.label = label
self.value = value
self.valueLabel = valueLabel
}
public var content: some HTML<HTMLTag.div> {
div(.class("grid grid-cols-1 justify-items-center")) {
p(.class("font-medium")) { label }
h3(.class("text-3xl font-extrabold")) {
value
if let valueLabel {
span(.class("text-lg ms-2")) { valueLabel }
}
}
}
}
}

View File

@@ -24,10 +24,12 @@ public struct WarningBox<Header: HTML>: HTML, Sendable {
public var content: some HTML<HTMLTag.div> {
div(.id("warnings")) {
header(warnings)
ul(.class("list-disc mx-10")) {
for warning in warnings {
li { warning }
if warnings.count > 0 {
header(warnings)
ul(.class("list-disc mx-10")) {
for warning in warnings {
li { warning }
}
}
}
}

View File

@@ -0,0 +1,78 @@
import Elementary
import ElementaryHTMX
import Routes
import Styleguide
struct HomePage: HTML, Sendable {
var content: some HTML {
div(.class("grid grid-cols-2 gap-4 justify-items-start")) {
group(
label: "Mold Risk",
description: MoldRisk.description,
svg: .thermometer,
route: .moldRisk(.index)
)
group(
label: "Dehumdifier Sizing",
description: DehumidifierSize.description,
svg: .droplets,
route: .dehumidifierSize(.index)
)
group(
label: "Attic Ventilation",
description: AtticVentilation.description,
svg: .wind,
route: .atticVentilation(.index)
)
group(
label: "HVAC System Performance",
description: HVACSystemPerformance.description,
svg: .thermometerSun,
route: .hvacSystemPerformance(.index)
)
group(
label: "Room Pressure",
description: RoomPressure.description,
svg: .leftRightArrow,
route: .roomPressure(.index)
)
group(
label: "Capacitor Calculator",
description: Capacitor.description,
svg: .zap,
route: .capacitor(.index)
)
group(
label: "Filter Sizing",
description: FilterPressureDrop.description,
svg: .funnel,
route: .filterPressureDrop(.index)
)
}
}
private func group(
label: String,
description: String,
svg: SVGType,
route: SiteRoute.View
) -> some HTML {
button(
.hx.get(route: route), .hx.target("#content"), .hx.pushURL(true),
.class("""
w-full rounded-xl shadow-lg border border-blue-600 justify-items-start
hover:bg-blue-600 hover:border-yellow-300 hover:text-yellow-300 transition-colors
""")
) {
div(.class("p-4 content-start")) {
div(.class("flex mb-6")) {
SVG(svg, color: .blue)
.attributes(.class("pe-2"))
h2(.class("font-bold text-2xl")) { label }
}
p { description }
}
}
}
}

View File

@@ -46,29 +46,51 @@ extension ViewController: DependencyKey {
@Dependency(\.psychrometricClient) var psychrometricClient
return .init(view: { request in
request.logger.debug("View route: \(request.route)")
let logger = request.logger
logger.debug("View route: \(request.route)")
switch request.route {
case .index:
return MainPage {
div {
div(.class("space-y-6")) {
div(.class("pb-8")) {
p(.class("dark:text-gray-200")) {
"Professional calculators for HVAC system design and troubleshooting."
}
}
div {
p(.class("font-2xl dark:text-gray-200 pb-6")) {
"SVG's"
}
div(.class("grid lg:grid-cols-8 gap-4")) {
for svg in SVGType.allCases {
SVG(svg, color: .blue)
}
h1(.class("font-extrabold text-4xl dark:text-gray-200")) {
"Professional Calculators"
}
p(.class("text-blue-500 font-bold")) {
"""
Professional calculators for HVAC system design and troublshooting.
"""
}
}
WarningBox(
"This site is still under construction, not all it's functionality is working."
)
HomePage()
}
}
case let .atticVentilation(route):
switch route {
case .index:
return request.respond(AtticVentilationForm(response: nil))
case let .submit(request):
let response = try await request.respond(logger: logger)
return AtticVentilationResponse(response: response)
}
case let .capacitor(route):
switch route {
case let .index(mode: mode):
logger.debug("Capacitor view: \(String(describing: mode))")
return request.respond(CapacitorForm(mode: mode, response: nil))
case let .submit(request):
logger.debug("Capaitor view submit: \(request)")
let response = try await request.respond(logger: logger)
return CapacitorResponse(response: response)
}
case let .dehumidifierSize(route):
switch route {
case .index:
@@ -78,6 +100,15 @@ extension ViewController: DependencyKey {
return DehumidifierSizeResult(response: response)
}
case let .filterPressureDrop(route):
switch route {
case let .index(mode: mode):
return request.respond(FilterPressureDropForm(mode: mode, response: nil))
case let .submit(request):
let response = try await request.respond(logger: logger)
return FilterPressureDropResponse(response: response)
}
case let .hvacSystemPerformance(route):
switch route {
case .index:
@@ -101,6 +132,10 @@ extension ViewController: DependencyKey {
switch route {
case let .index(mode):
return request.respond(RoomPressureForm(mode: mode, response: nil))
case let .submit(request):
let response = try await request.respond(logger: logger)
return RoomPressureResult(response: response)
}
}
})

View File

@@ -11,6 +11,14 @@ struct MainPage<Inner: HTML>: SendableHTMLDocument where Inner: Sendable {
var head: some HTML {
meta(.charset(.utf8))
meta(.name("viewport"), .content("width=device-width, initial-scale=1.0"))
meta(.name("author"), .content("Michael Housh and Dustin Cole"))
meta(.name("og:site-name"), .content("HVAC-Toolbox"))
meta(.name("apple-mobile-web-app-title"), .content("HVAC-Toolbox"))
meta(.name("format-detection"), .content("telephone=no"))
meta(.name("HandheldFriendly"), .content("True"))
meta(.name("MobileOptimized"), .content("320"))
meta(.name("keywords"), .content("hvac, HVAC, design, system-design, calculators"))
Elementary.title { self.title }
link(.rel(.stylesheet), .href("/output.css"))
link(
.rel(.icon),
@@ -39,6 +47,7 @@ struct MainPage<Inner: HTML>: SendableHTMLDocument where Inner: Sendable {
Header()
PageContent(body: inner)
}
Footer()
}
}
}
@@ -58,42 +67,54 @@ private struct Header: HTML {
.attributes(.class("group-hover:text-blue-600"))
}
}
nav(.class("flex flex-row gap-2 p-2 mt-2")) {
// TODO: Add class active, to button that is the active route.
ul(.class("flex flex-wrap gap-x-2 lg:gap-x-5 \(text: .yellow) font-bold")) {
li {
a(
.class("hover:border-b \(border: .yellow)"),
.hx.get(route: .moldRisk(.index)), .hx.target("#content"), .hx.pushURL(true)
) {
"Mold-Risk"
// nav(.class("flex flex-row gap-2 p-2 mt-2")) {
// // TODO: Add class active, to button that is the active route.
// ul(.class("flex flex-wrap gap-x-2 lg:gap-x-5 \(text: .yellow) font-bold")) {
// navLink(label: "Mold-Risk", route: .moldRisk(.index))
// navLink(label: "Dehumidifier-Sizing", route: .dehumidifierSize(.index))
// navLink(label: "HVAC-System-Performance", route: .hvacSystemPerformance(.index))
// navLink(label: "Room-Pressure", route: .roomPressure(.index))
// navLink(label: "Capcitor-Calculator", route: .capacitor(.index))
// navLink(label: "Attic-Ventilation", route: .atticVentilation(.index))
// }
// }
}
}
func navLink(label: String, route: SiteRoute.View) -> some HTML<HTMLTag.li> {
li {
a(.class("hover:border-b border-yellow-300"), .hx.get(route: route), .hx.target("#content"), .hx.pushURL(true)) {
label
}
}
}
}
private struct Footer: HTML {
var content: some HTML {
div(.class("bg-blue-500 text-yellow-300 text-sm font-semibold border-t border-yellow-300 mt-20")) {
div(.class("sm:grid sm:grid-cols-1 lg:flex lg:justify-between")) {
div(.class("grid grid-cols-1 p-4")) {
div(.class("flex")) {
span { "Toolbox icon by" }
a(.class("mx-1"), .href("https://dribbble.com/Laridae?ref=svgrepo.com"), .target(.blank)) {
u { "Laridae" }
}
span { "in CC Attribution License via" }
a(.class("mx-1"), .href("https://www.svgrepo.com/"), .target(.blank)) { u { "SVG Repo" } }
}
li {
a(
.class("[&:hover]:border-b \(border: .yellow)"),
.hx.get(route: .dehumidifierSize(.index)), .hx.target("#content"), .hx.pushURL(true)
) {
"Dehumidifier-Sizing"
}
}
li {
a(
.class("hover:border-b \(border: .yellow)"),
.hx.get(route: .hvacSystemPerformance(.index)), .hx.target("#content"), .hx.pushURL(true)
) {
"HVAC-System-Performance"
}
}
li {
a(
.class("hover:border-b \(border: .yellow)"),
.hx.get(route: .roomPressure(.index)), .hx.target("#content"), .hx.pushURL(true)
) {
"Room-Pressure"
}
div(.class("flex")) {
span { "Other SVG's by" }
a(.class("mx-1"), .href("https://lucide.dev"), .target(.blank)) { u { "Lucide" } }
span { "and licensed under" }
a(.class("mx-1"), .href("https://lucide.dev/license")) { u { "MIT." } }
}
}
div(.class("lg:p-8 sm:p-4")) {
p { "© Copyright 2025 Michael Housh and Dustin Cole" }
}
}
}
}

View File

@@ -0,0 +1,183 @@
import Elementary
import ElementaryHTMX
import Routes
import Styleguide
struct AtticVentilationForm: HTML, Sendable {
let response: AtticVentilation.Response?
var content: some HTML {
FormHeader(label: "Attic Ventilation Calculator", svg: .wind)
// Form.
form(
.hx.post(route: .atticVentilation(.index)),
.hx.target("#result")
) {
div(.class("space-y-6")) {
div {
LabeledContent(label: "Pressure Differential: (Pascals - WRT outside)") {
Input(id: "pressureDifferential", placeholder: "Pressure differential")
.attributes(.type(.number), .step("0.1"), .autofocus, .required)
}
p(.class("text-sm text-light mt-2")) { "Positive values indicate higher attic pressure" }
}
div(.class("grid grid-cols-1 md:grid-cols-2 gap-4")) {
div {
p(.class("font-bold mb-6")) { "Outdoor Conditions" }
LabeledContent(label: "Temperature: (°F)") {
Input(id: "outdoorTemperature", placeholder: "Outdoor temperature")
.attributes(.class("mb-4"), .type(.number), .step("0.1"), .required)
}
LabeledContent(label: "Dew Point: (°F)") {
Input(id: "outdoorDewpoint", placeholder: "Outdoor dewpoint")
.attributes(.type(.number), .step("0.1"), .required)
}
}
div {
p(.class("font-bold mb-6")) { "Attic Conditions" }
LabeledContent(label: "Temperature: (°F)") {
Input(id: "atticTemperature", placeholder: "Attic temperature")
.attributes(.class("mb-4"), .type(.number), .step("0.1"), .required)
}
LabeledContent(label: "Dew Point: (°F)") {
Input(id: "atticDewpoint", placeholder: "Attic dewpoint")
.attributes(.type(.number), .step("0.1"), .required)
}
}
}
LabeledContent(label: "Attic Floor Area: (ft²)") {
Input(id: "atticFloorArea", placeholder: "Attic floor area")
.attributes(.type(.number), .step("0.1"), .min("0.1"), .required)
}
div(.class("grid grid-cols-1 md:grid-cols-2 gap-4")) {
LabeledContent(label: "Existing Intake Area: (ft²)") {
Input(id: "existingIntakeArea", placeholder: "Intake area (optional)")
.attributes(.class("mb-4"), .type(.number), .step("0.1"), .min("0.1"), .required)
}
LabeledContent(label: "Existing Exhaust Area: (ft²)") {
Input(id: "existingExhaustArea", placeholder: "Exhaust area (optional)")
.attributes(.type(.number), .step("0.1"), .min("0.1"), .required)
}
}
div {
SubmitButton(label: "Calculate Ventilation")
}
}
}
div(.id("result")) {
if let response {
AtticVentilationResponse(response: response)
}
}
}
}
struct AtticVentilationResponse: HTML, Sendable {
let response: AtticVentilation.Response
var content: some HTML {
ResultContainer(reset: .atticVentilation(.index)) {
div(.class("space-y-6")) {
statusView()
requiredVentilationView()
WarningBox(warnings: response.warnings)
Note {
"""
Calculations are based on standard ventilation guidelines and building codes.
Local codes may vary. Consider consulting with a qualified professional for specific
recommendations.
"""
}
}
}
}
private func statusView() -> some HTML {
div(
.class("""
w-full rounded-lg shadow-lg border-2 p-6 \(response.ventilationStatus.borderColor)
\(response.ventilationStatus.textColor) \(response.ventilationStatus.backgroundColor)
""")
) {
div(.class("flex text-2xl mb-6")) {
SVG(.thermometerSun, color: response.ventilationStatus.textColor)
h4(.class("font-extrabold ms-2")) { "Ventilation Status: \(response.ventilationStatus.rawValue.capitalized)" }
}
div(.class("grid justify-items-stretch grid-cols-1 md:grid-cols-3")) {
group("Temperature Differential", "\(double: response.temperatureDifferential, fractionDigits: 1) °F")
group("Dewpoint Differential", "\(double: response.dewpointDifferential, fractionDigits: 1) °F")
group("Pressure Differential", "\(double: response.pressureDifferential, fractionDigits: 1) °F")
}
}
}
private func requiredVentilationView() -> some HTML {
div(
.class("""
w-full rounded-lg border p-6 border-blue-600 text-blue-600 bg-blue-100 dark:bg-blue-300
""")
) {
div(.class("flex text-2xl mb-6")) {
h4(.class("font-extrabold ms-2")) { "Required Ventilation" }
}
div(.class("grid grid-cols-1 md:grid-cols-2 content-center")) {
group("Intake", "\(double: response.requiredVentilationArea.intake, fractionDigits: 1) ft²")
group("Exhaust", "\(double: response.requiredVentilationArea.exhaust, fractionDigits: 1) ft²")
}
if response.recommendations.count > 0 {
div(.class("mt-8")) {
h4(.class("font-bold")) { "Recommendations:" }
ul(.class("list-disc mx-10")) {
for recommendation in response.recommendations {
li { recommendation }
}
}
}
}
}
}
// TODO: Use Styleguid.VerticalGroup
private func group(_ label: String, _ value: String) -> some HTML<HTMLTag.div> {
div(.class("justify-self-center")) {
div(.class("grid grid-cols-1 justify-items-center")) {
p(.class("font-medium")) { label }
h3(.class("text-3xl font-extrabold")) { value }
}
}
}
}
private extension AtticVentilation.VentilationStatus {
var backgroundColor: String {
switch self {
case .adequate: return "bg-green-200"
case .inadequate: return "bg-amber-200"
case .critical: return "bg-red-200"
}
}
var borderColor: String {
switch self {
case .adequate: return "border-green-500"
case .inadequate: return "border-amber-500"
case .critical: return "border-red-500"
}
}
var textColor: String {
switch self {
case .adequate: return "text-green-500"
case .inadequate: return "text-amber-500"
case .critical: return "text-red-500"
}
}
}

View File

@@ -0,0 +1,213 @@
import Elementary
import ElementaryHTMX
import Routes
import Styleguide
struct CapacitorForm: HTML, Sendable {
let mode: Capacitor.Mode
let response: Capacitor.Response?
init(mode: Capacitor.Mode?, response: Capacitor.Response? = nil) {
self.mode = mode ?? .test
self.response = response
}
var content: some HTML {
div(.class("relative")) {
div(.class("flex flex-wrap justify-between")) {
FormHeader(label: "Capacitor Calculator - \(mode.rawValue.capitalized) Capacitor", svg: .zap)
// Mode toggle / buttons.
// Mode toggle / buttons.
Toggle(
isOn: mode == .test,
onLabel: "Test Capacitor",
onAttributes: .hxDefaults(get: .capacitor(.index(mode: .test))),
offLabel: "Size Capacitor",
offAttributes: .hxDefaults(get: .capacitor(.index(mode: .size)))
)
.attributes(.class("mb-6"))
}
Form(mode: mode).attributes(.class("mt-6"))
div(.id("result")) {
if let response {
CapacitorResponse(response: response)
}
}
}
}
private struct Form: HTML, Sendable {
let mode: Capacitor.Mode
var content: some HTML<HTMLTag.form> {
form(
.hx.post(route: .capacitor(.index)),
.hx.target("#result")
) {
div(.class("space-y-6")) {
switch mode {
case .size:
LabeledContent(label: "Running Current: (amps)") {
Input(id: "runningAmps", placeholder: "Current amps")
.attributes(.type(.number), .step("0.1"), .min("0.1"), .autofocus, .required)
}
LabeledContent(label: "Line Voltage") {
Input(id: "lineVoltage", placeholder: "Voltage")
.attributes(.type(.number), .step("0.1"), .min("0.1"), .required)
}
LabeledContent(label: "Power Factor") {
Input(id: "powerFactor", placeholder: "Power factor (0-1)")
.attributes(.type(.number), .step("0.01"), .min("0.1"), .max("1.00"), .required)
}
case .test:
LabeledContent(label: "Start Winding: (amps)") {
Input(id: "startWindingAmps", placeholder: "Current amps")
.attributes(.type(.number), .step("0.1"), .min("0.1"), .autofocus, .required)
}
LabeledContent(label: "Run to Common: (volts)") {
Input(id: "runToCommonVoltage", placeholder: "Voltage")
.attributes(.type(.number), .step("0.1"), .min("0.1"), .required)
}
LabeledContent(label: "Capacitor Rated Size: (µF)") {
Input(id: "ratedCapacitorSize", placeholder: "Size (optional)")
.attributes(.type(.number), .step("0.1"), .min("0.1"))
}
}
div {
SubmitButton(label: "\(mode == .size ? "Calculate Size" : "Test Capacitor")")
}
}
}
}
}
}
struct CapacitorResponse: HTML, Sendable {
let response: Capacitor.Response
var content: some HTML {
ResultContainer(reset: .capacitor(.index(mode: response.mode))) {
switch response {
case let .size(result: result):
sizeResponse(result)
case let .test(result: result):
testResponse(result)
}
}
}
private func sizeResponse(_ response: Capacitor.Response.SizeResponse) -> some HTML {
div {
div(
.class("""
w-full rounded-lg shadow-lg p-4
border-2 border-blue-600 bg-blue-100 text-blue-600
""")
) {
div(.class("flex justify-center")) {
span(.class("font-extrabold")) { "Required Capacitor Size" }
}
row("Recommended Standard Size", "\(response.standardSize) µF")
row("Calculated Size:", "\(double: response.capacitance, fractionDigits: 1) µF")
toleranceRow(response.tolerance)
}
WarningBox(
"Always verify voltage rating matches the application.",
"Use the next larger size if exact match is unavailable.",
"Ensure capacitor is rated for continuous duty.",
"Consider ambient temperature in final selection."
) { _ in
span(.class("font-extrabold")) { "Important Notes:" }
}
}
}
private func testResponse(_ response: Capacitor.Response.TestResponse) -> some HTML {
div {
if let comparison = response.ratedComparison {
div(
.class("""
w-full rounded-lg shadow-lg p-4
border-2 \(comparison.borderColor) \(comparison.bgColor) \(comparison.textColor)
""")
) {
div(.class("flex font-bold gap-x-4")) {
SVG(
comparison.isInRange ? .checkCircle : .exclamation,
color: comparison.textColor
)
span(.class("font-normal")) {
"Capacitor is \(comparison.isInRange ? "within" : "outside of") acceptable range: (± 6% of rated value)"
}
}
}
}
div(.class("grid grid-cols-1 \(response.ratedComparison != nil ? "lg:grid-cols-2" : "") gap-4 mt-8")) {
div(.class("bg-blue-100 rounded-lg border-2 border-blue-600 text-blue-500 px-4 pb-4")) {
div(.class("flex justify-center mb-6 mt-2")) {
span(.class("text-2xl font-extrabold")) { "Measured" }
}
row("Capacitance", "\(double: response.capacitance) µF")
toleranceRow(response.tolerance)
}
if let comparison = response.ratedComparison {
div(.class("bg-blue-100 rounded-lg border-2 border-blue-600 text-blue-500 px-4 pb-4")) {
div(.class("flex justify-center mb-6 mt-2")) {
span(.class("text-2xl font-extrabold")) { "Rated Comparison" }
}
row("Rated Value", "\(comparison.value) µF")
row("Deviation", "\(double: comparison.percentDeviation, fractionDigits: 1)%")
}
}
}
}
}
private func row(_ label: String, _ value: String) -> some HTML {
div(.class("flex justify-between mb-2")) {
span(.class("font-semibold")) { label }
span { value }
}
}
private func toleranceRow(_ tolerance: Capacitor.Tolerance) -> some HTML {
row(
"Acceptable Range (±6%):",
"\(double: tolerance.minimum, fractionDigits: 1) - \(double: tolerance.maximum, fractionDigits: 1) µF"
)
}
}
private extension Capacitor.RatedComparison {
var bgColor: String {
guard isInRange else { return "bg-red-100" }
return "bg-green-100"
}
var borderColor: String {
guard isInRange else { return "border-red-600" }
return "border-green-600"
}
var textColor: String {
guard isInRange else { return "text-red-600" }
return "text-green-600"
}
}
private extension Capacitor.Response {
var mode: Capacitor.Mode {
switch self {
case .size: return .size
case .test: return .test
}
}
}

View File

@@ -0,0 +1,246 @@
import Elementary
import ElementaryHTMX
import Routes
import Styleguide
struct FilterPressureDropForm: HTML, Sendable {
let mode: FilterPressureDrop.Mode
let response: FilterPressureDrop.Response?
init(mode: FilterPressureDrop.Mode? = nil, response: FilterPressureDrop.Response? = nil) {
self.mode = mode ?? .basic
self.response = response
}
var content: some HTML {
div(.class("flex flex-wrap justify-between")) {
FormHeader(label: "Filter Pressure Drop - \(mode.label)", svg: .funnel)
Toggle(
isOn: mode == .basic,
onLabel: FilterPressureDrop.Mode.basic.label,
onAttributes: .hxDefaults(get: .filterPressureDrop(.index(mode: .basic))),
offLabel: FilterPressureDrop.Mode.fanLaw.label,
offAttributes: .hxDefaults(get: .filterPressureDrop(.index(mode: .fanLaw)))
)
.attributes(.class("mb-6"))
}
form(
.hx.post(route: .filterPressureDrop(.index)),
.hx.target("#result")
) {
div(.class("space-y-6")) {
switch mode {
case .basic:
BasicFormFields()
case .fanLaw:
FanLawFormFields()
}
div {
SubmitButton(label: "Calculate Pressure Drop")
}
}
}
div(.id("result")) {
if let response {
FilterPressureDropResponse(response: response)
}
}
}
private struct BasicFormFields: HTML, Sendable {
var content: some HTML {
div {
div {
h4(.class("text-lg font-bold mb-4")) { "System Parameters" }
div(.class("grid grid-cols-1 lg:grid-cols-3 gap-x-4 space-y-6")) {
LabeledContent(label: "System Size: (Tons)") {
Input(id: "systemSize", placeholder: "Tons")
.attributes(.type(.number), .step("0.5"), .min("1.0"), .autofocus, .required)
}
div {
InputLabel(for: "climateZone") { "Climate Zone" }
Select(for: ClimateZone.ZoneType.self, id: "climateZone") {
$0.label
}
}
div {
InputLabel(for: "filterType") { "Filter Type" }
Select(for: FilterPressureDrop.FilterType.self, id: "filterType") {
$0.label
}
}
}
}
div(.class("mt-6 lg:mt-0")) {
h4(.class("text-lg font-bold mb-4")) { "Filter Parameters" }
div(.class("grid grid-cols-1 lg:grid-cols-2 gap-x-4 space-y-6")) {
LabeledContent(label: "Width: (in)") {
Input(id: "filterWidth", placeholder: "Width")
.attributes(.type(.number), .step("0.1"), .min("0.1"), .required)
}
LabeledContent(label: "Height: (in)") {
Input(id: "filterHeight", placeholder: "Height")
.attributes(.type(.number), .step("0.1"), .min("0.1"), .required)
}
}
}
}
}
}
private struct FanLawFormFields: HTML, Sendable {
var content: some HTML {
div {
div(.class("mt-6 lg:mt-0")) {
h4(.class("text-lg font-bold mb-4")) { "Filter Parameters" }
div(.class("grid grid-cols-1 lg:grid-cols-3 gap-x-4 space-y-6")) {
LabeledContent(label: "Width: (in)") {
Input(id: "filterWidth", placeholder: "Width")
.attributes(.type(.number), .step("0.1"), .min("0.1"), .required, .autofocus)
}
LabeledContent(label: "Height: (in)") {
Input(id: "filterHeight", placeholder: "Height")
.attributes(.type(.number), .step("0.1"), .min("0.1"), .required)
}
LabeledContent(label: "Depth: (in)") {
Input(id: "filterDepth", placeholder: "Depth")
.attributes(.type(.number), .step("1.0"), .min("1.0"), .required)
}
}
}
div(.class("mt-6 lg:mt-0")) {
h4(.class("text-lg font-bold mb-4")) { "Airflow Parameters" }
div(.class("grid grid-cols-1 lg:grid-cols-3 gap-x-4 space-y-6")) {
LabeledContent(label: "Rated Airflow: (CFM)") {
Input(id: "ratedAirflow", placeholder: "Rated or measured Airflow")
.attributes(.type(.number), .step("0.1"), .min("0.1"), .required)
}
LabeledContent(label: "Filter Pressure Drop: (in. w.c.)") {
Input(id: "ratedPressureDrop", placeholder: "Rated or measured pressure drop")
.attributes(.type(.number), .step("0.1"), .min("0.1"), .required)
}
LabeledContent(label: "Design Airflow: (CFM)") {
Input(id: "designAirflow", placeholder: "Design or target airflow")
.attributes(.type(.number), .step("1.0"), .min("1.0"), .required)
}
}
}
}
}
}
}
struct FilterPressureDropResponse: HTML, Sendable {
let response: FilterPressureDrop.Response
var content: some HTML {
ResultContainer(reset: .filterPressureDrop(.index(mode: response.resetMode))) {
switch response {
case let .basic(response):
BasicView(response: response)
case let .fanLaw(response):
FanLawView(response: response)
}
}
}
private struct BasicView: HTML {
let response: FilterPressureDrop.Response.Basic
var content: some HTML {
div(
.class("""
grid grid-cols-1 lg:grid-cols-2 gap-6 rounded-lg shadow-lg
border border-blue-600 text-blue-600 bg-blue-100 p-6
""")
) {
VerticalGroup(
label: "Filter Face Area",
value: "\(double: response.filterArea, fractionDigits: 2)",
valueLabel: "ft²"
)
VerticalGroup(
label: "Filter Face Velocity",
value: "\(double: response.feetPerMinute, fractionDigits: 1)",
valueLabel: "FPM"
)
VerticalGroup(
label: "Initial Pressure Drop",
value: "\(double: response.initialPressureDrop, fractionDigits: 2)\"",
valueLabel: "w.c."
)
VerticalGroup(
label: "Maximum Allowable Pressure Drop",
value: "\(double: response.maxPressureDrop, fractionDigits: 2)\"",
valueLabel: "w.c."
)
}
WarningBox(warnings: response.warnings)
}
}
private struct FanLawView: HTML {
let response: FilterPressureDrop.Response.FanLaw
var content: some HTML {
div(
.class("""
grid grid-cols-1 lg:grid-cols-2 gap-6 rounded-lg shadow-lg
border border-blue-600 text-blue-600 bg-blue-100 p-6
""")
) {
VerticalGroup(
label: "Filter Face Velocity",
value: "\(double: response.faceVelocity, fractionDigits: 1)",
valueLabel: "FPM"
)
VerticalGroup(
label: "Predicted Pressure Drop",
value: "\(double: response.predictedPressureDrop, fractionDigits: 2)\"",
valueLabel: "w.c."
)
VerticalGroup(
label: "Velocity Ratio",
value: "\(double: response.velocityRatio, fractionDigits: 2)"
)
VerticalGroup(
label: "Pressure Ratio",
value: "\(double: response.pressureRatio, fractionDigits: 2)"
)
}
Note {
"""
Predictions are based on fan laws where pressure drop varies with the square of the airflow ratio.
Results assume similar air properties and filter loading conditions.
"""
}
}
}
}
private extension FilterPressureDrop.Mode {
var label: String {
switch self {
case .basic: return rawValue.capitalized
case .fanLaw: return "Fan Law"
}
}
}
private extension FilterPressureDrop.Response {
var resetMode: FilterPressureDrop.Mode {
switch self {
case .basic: return .basic
case .fanLaw: return .fanLaw
}
}
}

View File

@@ -14,47 +14,20 @@ struct RoomPressureForm: HTML, Sendable {
var content: some HTML {
div(.class("relative")) {
FormHeader(label: "Room Pressure Calculator", svg: .leftRightArrow)
// Mode toggle / buttons.
div(.class("absolute top-0 right-0 flex items-center gap-x-0")) {
switch mode {
case .knownAirflow:
SecondaryButton(label: "Known Airflow")
.attributes(.class("rounded-s-lg"))
.attributes(.disabled, when: mode == .knownAirflow)
.attributes(
.hx.get(route: .roomPressure(.index(mode: .knownAirflow))),
.hx.target("#content"),
when: mode == .measuredPressure
)
PrimaryButton(label: "Measured Pressure")
.attributes(.class("rounded-e-lg"))
.attributes(
.hx.get(route: .roomPressure(.index(mode: .measuredPressure))),
.hx.target("#content"),
when: mode == .knownAirflow
)
case .measuredPressure:
PrimaryButton(label: "Known Airflow")
.attributes(.class("rounded-s-lg"))
.attributes(
.hx.get(route: .roomPressure(.index(mode: .knownAirflow))),
.hx.target("#content"),
when: mode == .measuredPressure
)
SecondaryButton(label: "Measured Pressure")
.attributes(.class("rounded-e-lg"))
.attributes(.disabled, when: mode == .measuredPressure)
.attributes(
.hx.get(route: .roomPressure(.index(mode: .measuredPressure))),
.hx.target("#content"),
when: mode == .knownAirflow
)
}
div(.class("flex flex-wrap justify-between")) {
FormHeader(label: "Room Pressure Calculator - \(mode.label)", svg: .leftRightArrow)
Toggle(
isOn: mode == .knownAirflow,
onLabel: "Known Airflow",
onAttributes: .hxDefaults(get: .roomPressure(.index(mode: .knownAirflow))),
offLabel: "Measured Pressure",
offAttributes: .hxDefaults(get: .roomPressure(.index(mode: .measuredPressure)))
)
.attributes(.class("mb-6"))
}
Form(mode: mode)
.attributes(.class("mt-6"))
div(.id("result")) {
if let response {
@@ -68,7 +41,10 @@ struct RoomPressureForm: HTML, Sendable {
let mode: RoomPressure.Mode
var content: some HTML<HTMLTag.form> {
form {
form(
.hx.post(route: .roomPressure(.index)),
.hx.target("#result")
) {
div(.class("space-y-6")) {
LabeledContent(label: pressureLabel) {
// NB: using .attributes(..., when:...) not working, so using a switch statement.
@@ -150,6 +126,7 @@ struct RoomPressureForm: HTML, Sendable {
var content: some HTML {
InputLabel(for: "preferredGrilleHeight") { "Preferred Grille Height" }
// TODO: Use Styleguide.Select
select(
.id("preferredGrilleHeight"),
.name("preferredGrilleHeight"),
@@ -165,4 +142,86 @@ struct RoomPressureForm: HTML, Sendable {
struct RoomPressureResult: HTML, Sendable {
let response: RoomPressure.Response
var content: some HTML {
ResultContainer(reset: .roomPressure(.index(mode: response.mode))) {
div(.class("grid grid-cols-1 lg:grid-cols-2 gap-4")) {
RoundedContainer(title: "Return / Transfer Grille") {
div(.class("flex justify-between mt-6")) {
span(.class("font-semibold")) { "Standard Size:" }
span { """
\(response.grilleSize.width)" x \(response.grilleSize.height)"
""" }
}
div(.class("flex justify-between mt-3")) {
span(.class("font-semibold")) { "Required Net Free Area:" }
span {
"""
\(double: response.grilleSize.area, fractionDigits: 1) in
"""
sup { "2" }
}
}
div(.class("mt-8 text-sm")) {
span(.class("font-semibold")) { "Note: " }
span {
"Select a grille with at least \(double: response.grilleSize.area, fractionDigits: 1) in"
sup { "2" }
span { " net free area." }
}
}
}
.attributes(.class("bg-blue-100 border border-blue-600 text-blue-600"))
RoundedContainer(title: "Return / Transfer Duct") {
div(.class("flex justify-between mt-6")) {
span(.class("font-semibold")) { "Standard Size:" }
span { "\(response.ductSize.diameter)\"" }
}
div(.class("flex justify-between mt-3")) {
span(.class("font-semibold")) { "Air Velocity:" }
span { "\(double: response.ductSize.velocity, fractionDigits: 1) FPM" }
}
}
.attributes(.class("bg-purple-100 border border-purple-600 text-purple-600"))
}
WarningBox(warnings: response.warnings)
Note {
"""
Calculations are based on a target velocity of 400 FPM for return/transfer air paths.
The required net free area is the minimum needed - select a grille that meets or exceeds this value.
Verify manufacturer specifications for actual net free area of selected grilles.
"""
}
}
}
private struct RoundedContainer<Body: HTML>: HTML, Sendable where Body: Sendable {
let title: String
let body: Body
init(title: String, @HTMLBuilder body: () -> Body) {
self.title = title
self.body = body()
}
var content: some HTML<HTMLTag.div> {
div(.class("rounded-xl p-6")) {
h4(.class("text-xl font-bold")) { title }
body
}
}
}
}
private extension RoomPressure.Mode {
var label: String {
switch self {
case .knownAirflow: return "Known Airflow"
case .measuredPressure: return "Measured Pressure"
}
}
}

View File

@@ -1,8 +1,9 @@
docker_image := "hvac-toolbox"
docker_tag := "latest"
docker_registiry := "registry.digitalocean.com/swift-hvac-toolbox"
build-docker:
@docker build -t {{docker_image}}:{{docker_tag}} .
build-docker platform="linux/arm64":
@docker build --platform {{platform}} -t {{docker_registiry}}/{{docker_image}}:{{docker_tag}} .
run:
#!/usr/bin/env zsh
@@ -16,3 +17,9 @@ run-css:
clean:
@rm -rf .build
push-image:
@docker push {{docker_registiry}}/{{docker_image}}:{{docker_tag}}
build-docker-production:
@docker build --platform "linux/amd64" -t {{docker_registiry}}/{{docker_image}}:{{docker_tag}} .