feat: Adds attic ventilation calculation.

This commit is contained in:
2025-03-01 22:24:06 -05:00
parent 3c7147ad0e
commit 0ff7d6666c
11 changed files with 509 additions and 5 deletions

File diff suppressed because one or more lines are too long

View File

@@ -28,6 +28,10 @@ extension ApiController: DependencyKey {
logger.debug("API Route: \(route)") logger.debug("API Route: \(route)")
switch route { switch route {
case let .calculateAtticVentilation(request):
logger.debug("Calculating attic ventilation: \(request)")
return try await request.respond(logger: logger)
case let .calculateCapacitor(request): case let .calculateCapacitor(request):
logger.debug("Calculating capacitor: \(request)") logger.debug("Calculating capacitor: \(request)")
return try await request.respond(logger: logger) return try await request.respond(logger: logger)

View File

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

View File

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

View File

@@ -23,6 +23,7 @@ public extension SiteRoute {
enum Api: Equatable, Sendable { enum Api: Equatable, Sendable {
case calculateAtticVentilation(AtticVentilation.Request)
case calculateCapacitor(Capacitor.Request) case calculateCapacitor(Capacitor.Request)
case calculateDehumidifierSize(DehumidifierSize.Request) case calculateDehumidifierSize(DehumidifierSize.Request)
case calculateHVACSystemPerformance(HVACSystemPerformance.Request) case calculateHVACSystemPerformance(HVACSystemPerformance.Request)
@@ -32,6 +33,11 @@ public extension SiteRoute {
static let rootPath = Path { "api"; "v1" } static let rootPath = Path { "api"; "v1" }
public static let router = OneOf { public static let router = OneOf {
Route(.case(Self.calculateAtticVentilation)) {
Path { "api"; "v1"; "calculateAtticPressure" }
Method.post
Body(.json(AtticVentilation.Request.self))
}
Route(.case(Self.calculateCapacitor)) { Route(.case(Self.calculateCapacitor)) {
Path { "api"; "v1"; "calculateRoomPressure" } Path { "api"; "v1"; "calculateRoomPressure" }
Method.post Method.post
@@ -75,6 +81,7 @@ public extension SiteRoute {
enum View: Equatable, Sendable { enum View: Equatable, Sendable {
case index case index
case atticVentilation(AtticVentilation)
case capacitor(Capacitor) case capacitor(Capacitor)
case dehumidifierSize(DehumidifierSize) case dehumidifierSize(DehumidifierSize)
case hvacSystemPerformance(HVACSystemPerformance) case hvacSystemPerformance(HVACSystemPerformance)
@@ -85,6 +92,9 @@ public extension SiteRoute {
Route(.case(Self.index)) { Route(.case(Self.index)) {
Method.get Method.get
} }
Route(.case(Self.atticVentilation)) {
AtticVentilation.router
}
Route(.case(Self.capacitor)) { Route(.case(Self.capacitor)) {
Capacitor.router Capacitor.router
} }
@@ -102,6 +112,37 @@ 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 { public enum Capacitor: Equatable, Sendable {
case index(mode: Routes.Capacitor.Mode? = nil) case index(mode: Routes.Capacitor.Mode? = nil)
case submit(Routes.Capacitor.Request) case submit(Routes.Capacitor.Request)

View File

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

View File

@@ -52,7 +52,7 @@ public struct Input: HTML, Sendable {
.class(""" .class("""
w-full px-4 py-2 border rounded-md w-full px-4 py-2 border rounded-md
focus:ring-2 focus:ring-yellow-800 focus:border-yellow-800 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 invalid:border-red-500 out-of-range:border-red-500
""") """)
) )

View File

@@ -76,6 +76,15 @@ extension ViewController: DependencyKey {
} }
} }
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): case let .capacitor(route):
switch route { switch route {
case let .index(mode: mode): case let .index(mode: mode):

View File

@@ -67,6 +67,7 @@ private struct Header: HTML {
navLink(label: "HVAC-System-Performance", route: .hvacSystemPerformance(.index)) navLink(label: "HVAC-System-Performance", route: .hvacSystemPerformance(.index))
navLink(label: "Room-Pressure", route: .roomPressure(.index)) navLink(label: "Room-Pressure", route: .roomPressure(.index))
navLink(label: "Capcitor-Calculator", route: .capacitor(.index)) navLink(label: "Capcitor-Calculator", route: .capacitor(.index))
navLink(label: "Attic-Ventilation", route: .atticVentilation(.index))
} }
} }
} }

View File

@@ -0,0 +1,182 @@
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 }
}
}
}
}
}
}
private func group(_ label: String, _ value: String) -> some HTML<HTMLTag.div> {
div(.class("justify-self-center")) {
div(.class("grid grid-cols-1 justify-items-center")) {
p(.class("font-medium")) { label }
h3(.class("text-3xl font-extrabold")) { value }
}
}
}
}
private extension AtticVentilation.VentilationStatus {
var backgroundColor: String {
switch self {
case .adequate: return "bg-green-200"
case .inadequate: return "bg-amber-200"
case .critical: return "bg-red-200"
}
}
var borderColor: String {
switch self {
case .adequate: return "border-green-500"
case .inadequate: return "border-amber-500"
case .critical: return "border-red-500"
}
}
var textColor: String {
switch self {
case .adequate: return "text-green-500"
case .inadequate: return "text-amber-500"
case .critical: return "text-red-500"
}
}
}

View File

@@ -249,8 +249,8 @@ private extension RoomPressure.Mode {
var label: String { var label: String {
switch self { switch self {
case let .knownAirflow: return "Known Airflow" case .knownAirflow: return "Known Airflow"
case let .measuredPressure: return "Measured Pressure" case .measuredPressure: return "Measured Pressure"
} }
} }
} }