feat: Adds attic ventilation calculation.
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -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)
|
||||||
|
|||||||
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.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
102
Sources/Routes/Models/AtticVentilation.swift
Normal file
102
Sources/Routes/Models/AtticVentilation.swift
Normal 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
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
""")
|
""")
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
182
Sources/ViewController/Views/AtticVentilation.swift
Normal file
182
Sources/ViewController/Views/AtticVentilation.swift
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user