feat: Working on dehumidifier sizing, api and routes implemented, views are not complete.
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -26,8 +26,11 @@ extension ApiController: DependencyKey {
|
||||
|
||||
return .init(json: { route, logger in
|
||||
switch route {
|
||||
case let .calculateDehumidifierSize(request):
|
||||
logger.debug("Calculating dehumidifier size: \(request)")
|
||||
return try await request.respond(logger)
|
||||
case let .calculateMoldRisk(request):
|
||||
logger.info("Calculating mold risk: \(request)")
|
||||
logger.debug("Calculating mold risk: \(request)")
|
||||
return try await psychrometricClient.respond(request, logger)
|
||||
}
|
||||
})
|
||||
|
||||
116
Sources/ApiController/Extensions/DehumidifierSize.swift
Normal file
116
Sources/ApiController/Extensions/DehumidifierSize.swift
Normal file
@@ -0,0 +1,116 @@
|
||||
import Logging
|
||||
import Routes
|
||||
|
||||
public extension DehumidifierSize.Request {
|
||||
|
||||
private static let minTempEfficiency = 65.0
|
||||
private static let minRHEfficiency = 60.0
|
||||
private static let dehumidifierSizes = [
|
||||
33: "https://www.santa-fe-products.com/product/ultramd33-dehumidifier/",
|
||||
70: "https://www.santa-fe-products.com/product/santa-fe-ultra70-dehumidifier/",
|
||||
100: "https://www.santa-fe-products.com/product/ultra98-dehumidifier/",
|
||||
118: "https://www.santa-fe-products.com/product/ultra120-dehumidifier/",
|
||||
155: "https://www.santa-fe-products.com/product/ultra1559dehumidifier/",
|
||||
205: "https://www.santa-fe-products.com/product/ultra205-dehumidifier/"
|
||||
]
|
||||
|
||||
func respond(_ logger: Logger) async throws -> DehumidifierSize.Response {
|
||||
try validate()
|
||||
|
||||
let pintsPerDay = (latentLoad / 1054) * 24
|
||||
var capacityFactor = 1.0
|
||||
var warnings: [String] = []
|
||||
|
||||
if temperature < Self.minTempEfficiency {
|
||||
if temperature < 60 {
|
||||
capacityFactor *= 0.5
|
||||
warnings.append(
|
||||
"Very low temperature will severely reduce dehumidifier efficiency."
|
||||
)
|
||||
} else {
|
||||
capacityFactor *= 0.7
|
||||
warnings.append(
|
||||
"Low temperature will severely reduce dehumidifier efficiency."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if humidity < Self.minRHEfficiency {
|
||||
capacityFactor *= 0.8
|
||||
warnings.append(
|
||||
"Low relative humidity will significantly reduce moisture removal rate."
|
||||
)
|
||||
}
|
||||
|
||||
let requiredCapacity = pintsPerDay / capacityFactor
|
||||
|
||||
addDefaultWarnings(&warnings)
|
||||
|
||||
// TODO: Return early here ??
|
||||
if requiredCapacity > 205 {
|
||||
logger.debug("Required capacity exceeds residential unit.")
|
||||
warnings.append(
|
||||
"Required capacity exceeds largest standard residential unit - consider multiple units or a commercial system"
|
||||
)
|
||||
}
|
||||
|
||||
let (recommendedSize, recommendedUrl) = parseRecommendedSize(requiredCapacity)
|
||||
|
||||
logger.debug("Recommend size: \(recommendedSize)")
|
||||
|
||||
return .init(
|
||||
requiredCapacity: requiredCapacity,
|
||||
pintsPerDay: pintsPerDay,
|
||||
recommendedSize: recommendedSize,
|
||||
recommendedUrl: recommendedUrl,
|
||||
warnings: warnings
|
||||
)
|
||||
}
|
||||
|
||||
private func validate() throws {
|
||||
guard latentLoad > 0 else {
|
||||
throw DehumidiferSizeValidationError.latentLoadShouldBeGreaterThanZero
|
||||
}
|
||||
guard temperature > 0 else {
|
||||
throw DehumidiferSizeValidationError.temperatureShouldBeGreaterThanZero
|
||||
}
|
||||
guard humidity > 0 else {
|
||||
throw DehumidiferSizeValidationError.humidityShouldBeGreaterThanZero
|
||||
}
|
||||
}
|
||||
|
||||
private func parseRecommendedSize(_ requiredCapacity: Double) -> (Int, String) {
|
||||
for (key, value) in Self.dehumidifierSizes where Double(key) >= requiredCapacity {
|
||||
return (key, value)
|
||||
}
|
||||
return (205, Self.dehumidifierSizes[205]!)
|
||||
}
|
||||
|
||||
private func addDefaultWarnings(_ warnings: inout [String]) {
|
||||
// High humidity warnings
|
||||
if humidity > 65 {
|
||||
warnings.append(
|
||||
"High relative humidity - unit may need to run continuously."
|
||||
)
|
||||
}
|
||||
|
||||
if humidity > 80 {
|
||||
warnings.append(
|
||||
"Extreme humidity levels - address moisture sources and improve ventilation before dehumidification."
|
||||
)
|
||||
}
|
||||
|
||||
// Temperature warnings
|
||||
if temperature < 60 {
|
||||
warnings.append(
|
||||
"Temperature too low for effective dehumidification - consider raising space temperature first."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum DehumidiferSizeValidationError: Error {
|
||||
case latentLoadShouldBeGreaterThanZero
|
||||
case temperatureShouldBeGreaterThanZero
|
||||
case humidityShouldBeGreaterThanZero
|
||||
}
|
||||
44
Sources/Routes/DehumidifierSizing.swift
Normal file
44
Sources/Routes/DehumidifierSizing.swift
Normal file
@@ -0,0 +1,44 @@
|
||||
import Foundation
|
||||
|
||||
public enum DehumidifierSize {
|
||||
|
||||
/// Represents the request for determining dehumidifier size based on
|
||||
/// latent load and indoor conditions.
|
||||
public struct Request: Codable, Equatable, Sendable {
|
||||
|
||||
public let latentLoad: Double
|
||||
public let temperature: Double
|
||||
public let humidity: Double
|
||||
|
||||
public init(latentLoad: Double, temperature: Double, humidity: Double) {
|
||||
self.latentLoad = latentLoad
|
||||
self.temperature = temperature
|
||||
self.humidity = humidity
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents the response for determining dehumidifier size based on
|
||||
/// latent load and indoor conditions.
|
||||
public struct Response: Codable, Equatable, Sendable {
|
||||
|
||||
public let requiredCapacity: Double
|
||||
public let pintsPerDay: Double
|
||||
public let recommendedSize: Int
|
||||
public let recommendedUrl: String?
|
||||
public let warnings: [String]
|
||||
|
||||
public init(
|
||||
requiredCapacity: Double,
|
||||
pintsPerDay: Double,
|
||||
recommendedSize: Int,
|
||||
recommendedUrl: String? = nil,
|
||||
warnings: [String] = []
|
||||
) {
|
||||
self.requiredCapacity = requiredCapacity
|
||||
self.pintsPerDay = pintsPerDay
|
||||
self.recommendedSize = recommendedSize
|
||||
self.recommendedUrl = recommendedUrl
|
||||
self.warnings = warnings
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,11 +22,18 @@ public enum SiteRoute: Equatable, Sendable {
|
||||
public extension SiteRoute {
|
||||
|
||||
enum Api: Equatable, Sendable {
|
||||
|
||||
case calculateDehumidifierSize(DehumidifierSize.Request)
|
||||
case calculateMoldRisk(MoldRisk.Request)
|
||||
|
||||
static let rootPath = Path { "api"; "v1" }
|
||||
|
||||
public static let router = OneOf {
|
||||
Route(.case(Self.calculateDehumidifierSize)) {
|
||||
Path { "api"; "v1"; "calculateDehumidifierSize" }
|
||||
Method.post
|
||||
Body(.json(DehumidifierSize.Request.self))
|
||||
}
|
||||
Route(.case(Self.calculateMoldRisk)) {
|
||||
Path { "api"; "v1"; "calculateMoldRisk" }
|
||||
Method.post
|
||||
@@ -40,17 +47,47 @@ public extension SiteRoute {
|
||||
enum View: Equatable, Sendable {
|
||||
|
||||
case index
|
||||
case dehumidifierSize(DehumidifierSize)
|
||||
case moldRisk(MoldRisk)
|
||||
|
||||
public static let router = OneOf {
|
||||
Route(.case(Self.index)) {
|
||||
Method.get
|
||||
}
|
||||
Route(.case(Self.dehumidifierSize)) {
|
||||
DehumidifierSize.router
|
||||
}
|
||||
Route(.case(Self.moldRisk)) {
|
||||
MoldRisk.router
|
||||
}
|
||||
}
|
||||
|
||||
public enum DehumidifierSize: Equatable, Sendable {
|
||||
case index
|
||||
case submit(Routes.DehumidifierSize.Request)
|
||||
|
||||
static let rootPath = "dehumidifier-size"
|
||||
|
||||
public static let router = OneOf {
|
||||
Route(.case(Self.index)) {
|
||||
Path { rootPath }
|
||||
Method.get
|
||||
}
|
||||
Route(.case(Self.submit)) {
|
||||
Path { rootPath }
|
||||
Method.post
|
||||
Body {
|
||||
FormData {
|
||||
Field("latentLoad") { Double.parser() }
|
||||
Field("temperature") { Double.parser() }
|
||||
Field("humidity") { Double.parser() }
|
||||
}
|
||||
.map(.memberwise(Routes.DehumidifierSize.Request.init))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum MoldRisk: Equatable, Sendable {
|
||||
case index
|
||||
case submit(Routes.MoldRisk.Request)
|
||||
|
||||
20
Sources/Styleguide/Note.swift
Normal file
20
Sources/Styleguide/Note.swift
Normal file
@@ -0,0 +1,20 @@
|
||||
import Elementary
|
||||
|
||||
public struct Note: HTML, Sendable {
|
||||
let label: String
|
||||
let text: String
|
||||
|
||||
public init(_ label: String = "Note:", _ text: () -> String) {
|
||||
self.label = label
|
||||
self.text = text()
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,7 @@ extension ViewController: DependencyKey {
|
||||
@Dependency(\.psychrometricClient) var psychrometricClient
|
||||
|
||||
return .init(view: { request in
|
||||
request.logger.debug("View route: \(request.route)")
|
||||
switch request.route {
|
||||
case .index:
|
||||
return MainPage {
|
||||
@@ -67,6 +68,16 @@ extension ViewController: DependencyKey {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case let .dehumidifierSize(route):
|
||||
switch route {
|
||||
case .index:
|
||||
return request.respond(DehumidifierSizeForm())
|
||||
case let .submit(sizeRequest):
|
||||
let response = try await sizeRequest.respond(request.logger)
|
||||
return DehumidifierSizeResult(response: response)
|
||||
}
|
||||
|
||||
case let .moldRisk(route):
|
||||
switch route {
|
||||
case .index:
|
||||
|
||||
96
Sources/ViewController/Views/DehumidifierSize.swift
Normal file
96
Sources/ViewController/Views/DehumidifierSize.swift
Normal file
@@ -0,0 +1,96 @@
|
||||
import Elementary
|
||||
import ElementaryHTMX
|
||||
import Routes
|
||||
import Styleguide
|
||||
|
||||
struct DehumidifierSizeForm: HTML {
|
||||
var content: some HTML {
|
||||
FormHeader(label: "Dehumidifier Sizing Calculator", svg: .droplets)
|
||||
form(
|
||||
// Using index to get the correct path, but really uses the `submit` end-point.
|
||||
.hx.post(route: .dehumidifierSize(.index)),
|
||||
.hx.target("#result")
|
||||
) {
|
||||
div(.class("space-y-6")) {
|
||||
LabeledContent(label: "Latent Load (BTU/h)") {
|
||||
Input(id: "latentLoad", placeholder: "Latent load from Manual-J")
|
||||
.attributes(.type(.number), .step("0.1"), .min("0.1"), .autofocus, .required)
|
||||
}
|
||||
|
||||
LabeledContent(label: "Indoor Temperature (°F)") {
|
||||
Input(id: "temperature", placeholder: "Indoor dry bulb temperature")
|
||||
.attributes(.type(.number), .step("0.1"), .min("0.1"), .required)
|
||||
}
|
||||
|
||||
LabeledContent(label: "Indoor Humdity (%)") {
|
||||
Input(id: "humidity", placeholder: "Relative humidity")
|
||||
.attributes(.type(.number), .step("0.1"), .min("0.1"), .required)
|
||||
}
|
||||
|
||||
div {
|
||||
SubmitButton(label: "Calculate Dehumidifier Size")
|
||||
}
|
||||
}
|
||||
}
|
||||
div(.id("result")) {}
|
||||
}
|
||||
}
|
||||
|
||||
struct DehumidifierSizeResult: HTML {
|
||||
let response: DehumidifierSize.Response
|
||||
|
||||
var content: some HTML {
|
||||
ResultContainer(reset: .dehumidifierSize(.index)) {
|
||||
div(.class("""
|
||||
p-2 rounded-lg shadow-lg bg-blue-500
|
||||
"""
|
||||
)) {
|
||||
p { "Recommended Size: \(response.recommendedSize)" }
|
||||
}
|
||||
|
||||
// Display warnings, if applicable
|
||||
if response.warnings.count > 0 {
|
||||
div(.class("w-full mt-8 p-4 rounded-lg shadow-lg text-amber-500 bg-amber-200 border border-amber-500")) {
|
||||
span(.class("font-semibold mb-4 border-b")) { "Warning\(response.warnings.count > 1 ? "s:" : ":")" }
|
||||
ul(.class("list-disc mx-10")) {
|
||||
for warning in response.warnings {
|
||||
li { warning }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Note {
|
||||
"""
|
||||
Sizing is based on continuous operation at rated conditions. Actual performance will vary based on
|
||||
operating conditions. Consider Energy Star rated units for better efficiency. For whole-house
|
||||
applications, ensure proper air distribution and drainage.
|
||||
"""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// <div className = "p-4 bg-white rounded-lg shadow-sm">
|
||||
// <p className = Recommended < "text-sm text-gray-600 mb-2" Size: </p>
|
||||
// <a
|
||||
// href = { result.recommendedSize.url }
|
||||
// target = "_blank"
|
||||
// rel = "noopener noreferrer"
|
||||
// className = "block p-4 bg-blue-100 rounded-lg hover:bg-blue-200 transition-colors group"
|
||||
// >
|
||||
// <div className = "flex items-center justify-between">
|
||||
// <div>
|
||||
// <span className = "text-2xl font-bold text-blue-700">
|
||||
// { result.recommendedSize.size } PPD
|
||||
// </span>
|
||||
// <p className = "text-sm text-blue-600 mt-1">
|
||||
// Click to view available models →
|
||||
// </p>
|
||||
// </div>
|
||||
// <div className = "w-12 h-12 bg-blue-200 rounded-full flex items-center justify-center group-hover:bg-blue-300 transition-colors">
|
||||
// <Droplets className = "w-6 h-6 text-blue-700" />
|
||||
// </div>
|
||||
// </div>
|
||||
// </a>
|
||||
// </div >
|
||||
@@ -1,4 +1,6 @@
|
||||
import Elementary
|
||||
import ElementaryHTMX
|
||||
import Routes
|
||||
import Styleguide
|
||||
|
||||
struct MainPage<Inner: HTML>: SendableHTMLDocument where Inner: Sendable {
|
||||
@@ -68,7 +70,10 @@ private struct Header: HTML {
|
||||
}
|
||||
}
|
||||
li {
|
||||
a(.href("#"), .class("[&:hover]:border-b \(border: .yellow)")) {
|
||||
a(
|
||||
.class("[&:hover]:border-b \(border: .yellow)"),
|
||||
.hx.get(route: .dehumidifierSize(.index)), .hx.target("#content"), .hx.pushURL(true)
|
||||
) {
|
||||
"Dehumidifier-Sizing"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Elementary
|
||||
import ElementaryHTMX
|
||||
import PsychrometricClient
|
||||
import Routes
|
||||
import Styleguide
|
||||
@@ -20,7 +21,7 @@ struct MoldRiskForm: HTML {
|
||||
.attributes(.type(.number), .step("0.1"), .min("0.1"), .autofocus)
|
||||
}
|
||||
|
||||
LabeledContent(label: "Indor Humdity (%)") {
|
||||
LabeledContent(label: "Indoor Humdity (%)") {
|
||||
Input(id: "humidity", placeholder: "Relative humidity")
|
||||
.attributes(.type(.number), .step("0.1"), .min("0.1"))
|
||||
}
|
||||
@@ -88,15 +89,12 @@ struct MoldRiskResponse: HTML {
|
||||
.attributes(.class("mx-6"))
|
||||
|
||||
// Disclaimer.
|
||||
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")) { "Note:" }
|
||||
"""
|
||||
These calculations are based on typical indoor conditions and common mold species. Actual mold growth can
|
||||
vary based on surface materials, air movement, and other environmental factors. Always address moisture
|
||||
issues promptly and consult professionals for severe cases.
|
||||
"""
|
||||
}
|
||||
Note {
|
||||
"""
|
||||
These calculations are based on typical indoor conditions and common mold species. Actual mold growth can
|
||||
vary based on surface materials, air movement, and other environmental factors. Always address moisture
|
||||
issues promptly and consult professionals for severe cases.
|
||||
"""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
2
justfile
2
justfile
@@ -4,7 +4,7 @@ run:
|
||||
touch .build/browser-dev-sync
|
||||
browser-sync start -p localhost:8080 --ws &
|
||||
watchexec -w Sources -e .swift -r 'swift build --product App && touch .build/browser-dev-sync' &
|
||||
watchexec -w .build/browser-dev-sync --ignore-nothing -r '.build/debug/App'
|
||||
watchexec -w .build/browser-dev-sync --ignore-nothing -r '.build/debug/App serve --log debug'
|
||||
|
||||
run-css:
|
||||
@pnpm run css-watch
|
||||
|
||||
Reference in New Issue
Block a user