Compare commits
13 Commits
3be0f7a828
...
9da4149391
| Author | SHA1 | Date | |
|---|---|---|---|
|
9da4149391
|
|||
|
1727e9a905
|
|||
|
8e0860af8e
|
|||
|
eb6ec446dc
|
|||
|
67488e06a9
|
|||
|
a8022ec80a
|
|||
|
d6d400b6ec
|
|||
|
0ff7d6666c
|
|||
|
3c7147ad0e
|
|||
|
eaf0387899
|
|||
|
5630820575
|
|||
|
af24ef3971
|
|||
|
c1aa2a8ad1
|
29
Dockerfile
29
Dockerfile
@@ -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
100
Dockerfile.dev
Normal 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"]
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
165
Sources/ApiController/Extensions/AtticVentilation.swift
Normal file
165
Sources/ApiController/Extensions/AtticVentilation.swift
Normal 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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
117
Sources/ApiController/Extensions/Capacitor.swift
Normal file
117
Sources/ApiController/Extensions/Capacitor.swift
Normal 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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import Foundation
|
||||
|
||||
extension Double {
|
||||
|
||||
func ensurePostive() -> Self {
|
||||
if self < 0 { return self * -1 }
|
||||
return self
|
||||
}
|
||||
}
|
||||
156
Sources/ApiController/Extensions/FilterPressureDrop.swift
Normal file
156
Sources/ApiController/Extensions/FilterPressureDrop.swift
Normal 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
|
||||
}
|
||||
207
Sources/ApiController/Extensions/RoomPressure.swift
Normal file
207
Sources/ApiController/Extensions/RoomPressure.swift
Normal 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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
3
Sources/ApiController/ValidationError.swift
Normal file
3
Sources/ApiController/ValidationError.swift
Normal file
@@ -0,0 +1,3 @@
|
||||
struct ValidationError: Error {
|
||||
let message: String
|
||||
}
|
||||
0
Sources/ClimateZoneClient/ClimateZoneClient.swift
Normal file
0
Sources/ClimateZoneClient/ClimateZoneClient.swift
Normal file
67
Sources/CoreModels/ClimateZone.swift
Normal file
67
Sources/CoreModels/ClimateZone.swift
Normal 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 }
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Sources/CoreModels/Cooridinates.swift
Normal file
11
Sources/CoreModels/Cooridinates.swift
Normal 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
|
||||
}
|
||||
}
|
||||
11
Sources/CoreModels/DesignTemperatures.swift
Normal file
11
Sources/CoreModels/DesignTemperatures.swift
Normal 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
|
||||
}
|
||||
}
|
||||
27
Sources/CoreModels/Location.swift
Normal file
27
Sources/CoreModels/Location.swift
Normal 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
|
||||
}
|
||||
}
|
||||
74
Sources/LocationClient/LocationClient.swift
Normal file
74
Sources/LocationClient/LocationClient.swift
Normal 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
|
||||
106
Sources/Routes/Models/AtticVentilation.swift
Normal file
106
Sources/Routes/Models/AtticVentilation.swift
Normal 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
|
||||
125
Sources/Routes/Models/Capacitor.swift
Normal file
125
Sources/Routes/Models/Capacitor.swift
Normal 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
|
||||
@@ -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: ",")))"
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
34
Sources/Styleguide/Row.swift
Normal file
34
Sources/Styleguide/Row.swift
Normal 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 {}
|
||||
@@ -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">
|
||||
|
||||
25
Sources/Styleguide/VerticalGroup.swift
Normal file
25
Sources/Styleguide/VerticalGroup.swift
Normal 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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
78
Sources/ViewController/HomePage.swift
Normal file
78
Sources/ViewController/HomePage.swift
Normal 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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
183
Sources/ViewController/Views/AtticVentilation.swift
Normal file
183
Sources/ViewController/Views/AtticVentilation.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
213
Sources/ViewController/Views/Capacitor.swift
Normal file
213
Sources/ViewController/Views/Capacitor.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
246
Sources/ViewController/Views/FilterPressureDrop.swift
Normal file
246
Sources/ViewController/Views/FilterPressureDrop.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
11
justfile
11
justfile
@@ -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}} .
|
||||
|
||||
Reference in New Issue
Block a user